@@ -190,6 +262,18 @@ export function NavBar({ stars, downloads }: NavBarProps) {
onClick={() => setIsMenuOpen(false)}>
Roo Code Cloud
+
setIsMenuOpen(false)}>
+ Roo Code for Slack
+
+
setIsMenuOpen(false)}>
+ Roo Code for Linear
+
{
+ const media = window.matchMedia("(prefers-reduced-motion: reduce)")
+ const onChange = () => setReduced(media.matches)
+ onChange()
+
+ if (typeof media.addEventListener === "function") {
+ media.addEventListener("change", onChange)
+ return () => media.removeEventListener("change", onChange)
+ }
+
+ media.addListener?.(onChange)
+ return () => media.removeListener?.(onChange)
+ }, [])
+
+ return reduced
+}
+
+type TypingDotsProps = {
+ className?: string
+}
+
+function TypingDots({ className }: TypingDotsProps): JSX.Element {
+ return (
+
+
+
+
+
+ )
+}
+
+function LinearIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ )
+}
+
+type ActivityRowProps = {
+ item: ActivityItem
+ isNew: boolean
+ reduceMotion: boolean
+}
+
+function ActivityRow({ item, isNew, reduceMotion }: ActivityRowProps): JSX.Element {
+ let animation = ""
+ if (!reduceMotion && isNew) {
+ animation = "animate-in fade-in slide-in-from-bottom-1 duration-300"
+ }
+
+ // Event items (status changes, etc.) - compact inline format
+ if (item.kind === "event") {
+ return (
+
+
+ {item.avatarText}
+
+
{item.author}
+
{item.body}
+
·
+
{item.timeLabel}
+
+ )
+ }
+
+ // PR link events
+ if (item.kind === "pr-link") {
+ return (
+
+
+ {item.body}
+ ·
+ {item.timeLabel}
+
+ )
+ }
+
+ // Comment items - more substantial with message body
+ return (
+
+
+ {item.avatarText}
+
+
+
+ {item.author}
+ ·
+ {item.timeLabel}
+
+
{item.body}
+
+
+ )
+}
+
+export type LinearIssueDemoProps = {
+ className?: string
+}
+
+export function LinearIssueDemo({ className }: LinearIssueDemoProps): JSX.Element {
+ const reduceMotion = usePrefersReducedMotion()
+ const [stepIndex, setStepIndex] = useState(0)
+ const scrollViewportRef = useRef
(null)
+
+ const activityItems: ActivityItem[] = useMemo(
+ () => [
+ {
+ id: "a1",
+ kind: "comment",
+ author: "Jordan",
+ avatarText: "J",
+ avatarClassName: "bg-amber-600 text-white",
+ body: (
+
+ @Roo Code Can you implement this feature?
+
+ ),
+ timeLabel: "2m ago",
+ },
+ {
+ id: "a2",
+ kind: "comment",
+ author: "Roo Code",
+ avatarText: "R",
+ avatarClassName: "bg-indigo-600 text-white",
+ body: Analyzing issue requirements and codebase... ,
+ timeLabel: "2m ago",
+ },
+ {
+ id: "a3",
+ kind: "event",
+ author: "Roo Code",
+ avatarText: "R",
+ avatarClassName: "bg-indigo-600 text-white",
+ body: moved to In Progress ,
+ timeLabel: "2m ago",
+ },
+ {
+ id: "a4",
+ kind: "comment",
+ author: "Roo Code",
+ avatarText: "R",
+ avatarClassName: "bg-indigo-600 text-white",
+ body: Planning implementation: Settings component with light/dark toggle. ,
+ timeLabel: "1m ago",
+ },
+ {
+ id: "a5",
+ kind: "comment",
+ author: "Jordan",
+ avatarText: "J",
+ avatarClassName: "bg-amber-600 text-white",
+ body: (
+
+ @Roo Code Please also add a "system" option
+ that follows OS preference.
+
+ ),
+ timeLabel: "1m ago",
+ },
+ {
+ id: "a6",
+ kind: "comment",
+ author: "Roo Code",
+ avatarText: "R",
+ avatarClassName: "bg-indigo-600 text-white",
+ body: (
+
+ Got it! Adding system preference detection using{" "}
+
+ prefers-color-scheme
+
+
+ ),
+ timeLabel: "30s ago",
+ },
+ {
+ id: "a7",
+ kind: "pr-link",
+ body: (
+
+ Roo Code linked{" "}
+ PR #847
+
+ ),
+ timeLabel: "just now",
+ },
+ {
+ id: "a8",
+ kind: "comment",
+ author: "Roo Code",
+ avatarText: "R",
+ avatarClassName: "bg-indigo-600 text-white",
+ body: (
+
+
+ PR ready for review:{" "}
+ #847
+
+
+
+
+ feat: add theme toggle with system preference
+
+
+142 -12 · 3 files changed
+
+
+ ),
+ timeLabel: "just now",
+ },
+ ],
+ [],
+ )
+
+ type DemoPhase =
+ | { kind: "issue" }
+ | { kind: "show"; activityIndex: number }
+ | { kind: "typing"; activityIndex: number }
+ | { kind: "reset" }
+
+ const phases: DemoPhase[] = useMemo(() => {
+ const next: DemoPhase[] = []
+
+ next.push({ kind: "issue" })
+
+ for (let activityIndex = 0; activityIndex < activityItems.length; activityIndex += 1) {
+ const item = activityItems[activityIndex]
+ if (item?.kind === "comment") {
+ next.push({ kind: "typing", activityIndex })
+ }
+ next.push({ kind: "show", activityIndex })
+ }
+ next.push({ kind: "reset" })
+ return next
+ }, [activityItems])
+
+ const lastShowPhaseIndex = useMemo(() => {
+ let lastIndex = -1
+ for (let idx = 0; idx < phases.length; idx += 1) {
+ if (phases[idx]?.kind === "show") lastIndex = idx
+ }
+ return lastIndex
+ }, [phases])
+
+ useEffect(() => {
+ if (reduceMotion) {
+ setStepIndex(lastShowPhaseIndex >= 0 ? lastShowPhaseIndex : 0)
+ return
+ }
+
+ const active = phases[stepIndex] ?? phases.at(0)
+ const isLastMessageShow = active?.kind === "show" && stepIndex === lastShowPhaseIndex
+ const durationMs = (() => {
+ const base = 2000
+ if (active?.kind === "reset") return 500
+ if (active?.kind === "issue") return 1500
+ if (active?.kind === "typing") return 800
+ return isLastMessageShow ? base * 2.5 : base
+ })()
+
+ const timer = window.setTimeout(() => {
+ const nextIndex = (stepIndex + 1) % phases.length
+ setStepIndex(nextIndex)
+ }, durationMs)
+
+ return () => window.clearTimeout(timer)
+ }, [lastShowPhaseIndex, phases, reduceMotion, stepIndex])
+
+ const activePhase = phases[stepIndex] ?? phases.at(0) ?? { kind: "issue" }
+
+ function getVisibleCount(phase: DemoPhase): number {
+ if (phase.kind === "reset" || phase.kind === "issue") return 0
+ if (phase.kind === "typing") return phase.activityIndex
+ return phase.activityIndex + 1
+ }
+
+ const visibleCount = getVisibleCount(activePhase)
+ const visibleActivities = activityItems.slice(0, visibleCount)
+ const typingTarget = activePhase.kind === "typing" ? activityItems[activePhase.activityIndex] : undefined
+
+ useEffect(() => {
+ const viewport = scrollViewportRef.current
+ if (!viewport) return
+
+ if (activePhase.kind === "reset" || activePhase.kind === "issue" || visibleCount <= 1) {
+ viewport.scrollTo({ top: 0, behavior: "auto" })
+ return
+ }
+
+ viewport.scrollTo({
+ top: viewport.scrollHeight,
+ behavior: reduceMotion ? "auto" : "smooth",
+ })
+ }, [activePhase.kind, reduceMotion, visibleCount])
+
+ const issueVisible = activePhase.kind !== "reset"
+
+ return (
+
+
+ {/* Linear-style Header with breadcrumb */}
+
+
+
Frontend
+
+
FE-312
+
+
+ Live demo
+
+
+
+ {/* Issue Content */}
+
+ {/* Issue Title */}
+
+
+ Add dark mode toggle to settings
+
+
+ Users should be able to switch between light and dark themes from the settings page. Persist
+ preference to localStorage and apply immediately.
+
+
+
+ {/* Activity Section */}
+
+
+ Activity
+ Unsubscribe
+
+
+
+ {visibleActivities.map((item) => (
+
+ ))}
+
+ {typingTarget && typingTarget.kind === "comment" && (
+
+
+ {typingTarget.avatarText}
+
+
+
+
+ {typingTarget.author}
+
+ typing
+
+
+
+
+ )}
+
+
+
+
+ {/* Comment Input */}
+
+
+
Leave a comment...
+
+
+
+
+
+
+ {/* Progress indicator */}
+
+
+ {activityItems.map((item, idx) => (
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/web-roo-code/src/components/providers/hubspot-provider.tsx b/apps/web-roo-code/src/components/providers/hubspot-provider.tsx
new file mode 100644
index 0000000000..9f0236d62b
--- /dev/null
+++ b/apps/web-roo-code/src/components/providers/hubspot-provider.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import Script from "next/script"
+import { hasConsent, onConsentChange } from "@/lib/analytics/consent-manager"
+
+// HubSpot Account ID
+const HUBSPOT_ID = "243714031"
+
+/**
+ * HubSpot Tracking Provider
+ * Loads HubSpot tracking script only after user consent is given, following GDPR requirements
+ */
+export function HubSpotProvider({ children }: { children: React.ReactNode }) {
+ const [shouldLoad, setShouldLoad] = useState(false)
+
+ useEffect(() => {
+ // Check initial consent status
+ if (hasConsent()) {
+ setShouldLoad(true)
+ }
+
+ // Listen for consent changes
+ const unsubscribe = onConsentChange((consented) => {
+ if (consented) {
+ setShouldLoad(true)
+ }
+ })
+
+ return unsubscribe
+ }, [])
+
+ return (
+ <>
+ {shouldLoad && (
+ <>
+ {/* HubSpot Embed Code */}
+
+ >
+ )}
+ {children}
+ >
+ )
+}
diff --git a/apps/web-roo-code/src/components/providers/providers.tsx b/apps/web-roo-code/src/components/providers/providers.tsx
index a0e532b5e4..f97c692145 100644
--- a/apps/web-roo-code/src/components/providers/providers.tsx
+++ b/apps/web-roo-code/src/components/providers/providers.tsx
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ThemeProvider } from "next-themes"
import { GoogleTagManagerProvider } from "./google-tag-manager-provider"
+import { HubSpotProvider } from "./hubspot-provider"
import { PostHogProvider } from "./posthog-provider"
const queryClient = new QueryClient()
@@ -12,11 +13,13 @@ export const Providers = ({ children }: { children: React.ReactNode }) => {
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
)
diff --git a/apps/web-roo-code/src/components/slack/slack-thread-demo.tsx b/apps/web-roo-code/src/components/slack/slack-thread-demo.tsx
new file mode 100644
index 0000000000..9a13c17f80
--- /dev/null
+++ b/apps/web-roo-code/src/components/slack/slack-thread-demo.tsx
@@ -0,0 +1,548 @@
+"use client"
+
+import type { ReactNode } from "react"
+import { useEffect, useMemo, useRef, useState } from "react"
+import { CheckCircle2, Paperclip } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+type SlackMessage = {
+ id: string
+ author: string
+ timeLabel: string
+ body: ReactNode
+ avatarText: string
+ avatarClassName: string
+ kind: "human" | "bot"
+}
+
+function usePrefersReducedMotion(): boolean {
+ const [reduced, setReduced] = useState(false)
+
+ useEffect(() => {
+ const media = window.matchMedia("(prefers-reduced-motion: reduce)")
+ const onChange = () => setReduced(media.matches)
+ onChange()
+
+ if (typeof media.addEventListener === "function") {
+ media.addEventListener("change", onChange)
+ return () => media.removeEventListener("change", onChange)
+ }
+
+ media.addListener?.(onChange)
+ return () => media.removeListener?.(onChange)
+ }, [])
+
+ return reduced
+}
+
+type TypingDotsProps = {
+ className?: string
+}
+
+function TypingDots({ className }: TypingDotsProps): JSX.Element {
+ return (
+
+
+
+
+
+ )
+}
+
+type FakeLinkProps = {
+ children: ReactNode
+ className?: string
+}
+
+function FakeLink({ children, className }: FakeLinkProps): JSX.Element {
+ return (
+
+ {children}
+
+ )
+}
+
+type SlackMessageRowProps = {
+ message: SlackMessage
+ isNew: boolean
+ reduceMotion: boolean
+}
+
+function SlackMessageRow({ message, isNew, reduceMotion }: SlackMessageRowProps): JSX.Element {
+ let animation = ""
+ if (!reduceMotion && isNew) {
+ animation = "animate-in fade-in slide-in-from-bottom-2 duration-500"
+ }
+
+ return (
+
+
+ {message.avatarText}
+
+
+
+ {message.author}
+ {message.timeLabel}
+ {message.kind === "bot" && (
+
+
+ App
+
+ )}
+
+
{message.body}
+
+
+ )
+}
+
+export type SlackThreadDemoProps = {
+ className?: string
+}
+
+export function SlackThreadDemo({ className }: SlackThreadDemoProps): JSX.Element {
+ const reduceMotion = usePrefersReducedMotion()
+ const [stepIndex, setStepIndex] = useState(0)
+ const scrollViewportRef = useRef(null)
+
+ const messages: SlackMessage[] = useMemo(
+ () => [
+ {
+ id: "m1",
+ author: "Avery Lee",
+ timeLabel: "Monday at 2:56 PM",
+ avatarText: "AL",
+ avatarClassName: "bg-[#2B2D31] text-[#F8F8F9] ring-1 ring-white/10",
+ kind: "human",
+ body: (
+ We need to add a page to our Marketing site that highlights using Roo Code from Slack.
+ ),
+ },
+ {
+ id: "m2",
+ author: "Avery Lee",
+ timeLabel: "Monday at 2:58 PM",
+ avatarText: "AL",
+ avatarClassName: "bg-[#2B2D31] text-[#F8F8F9] ring-1 ring-white/10",
+ kind: "human",
+ body: (
+
+
+ The documentation for using Roo Code from Slack is here:{" "}
+
+ https://docs.roocode.com/roo-code-cloud/slack-integration
+
+
+
Here are some pages from our site we can use for guidance:
+
+
+ https://roocode.com
+
+
+ https://roocode.com/extension
+
+
+ https://roocode.com/cloud
+
+
+
+ ),
+ },
+ {
+ id: "m3",
+ author: "Avery Lee",
+ timeLabel: "Monday at 3:08 PM",
+ avatarText: "AL",
+ avatarClassName: "bg-[#2B2D31] text-[#F8F8F9] ring-1 ring-white/10",
+ kind: "human",
+ body: (
+
+
This is the start of a wireframe I have in mind for this page
+
+
+ ),
+ },
+ {
+ id: "m4",
+ author: "Avery Lee",
+ timeLabel: "Monday at 3:09 PM",
+ avatarText: "AL",
+ avatarClassName: "bg-[#2B2D31] text-[#F8F8F9] ring-1 ring-white/10",
+ kind: "human",
+ body: (
+
+ @Roomote let's create
+ the plan to deliver this
+
+ ),
+ },
+ {
+ id: "m5",
+ author: "Roomote",
+ timeLabel: "Monday at 3:09 PM",
+ avatarText: "R",
+ avatarClassName: "bg-violet-500/20 text-violet-200 ring-1 ring-violet-500/30",
+ kind: "bot",
+ body: (
+
+
+ Calling Planneroo to get started on
+ your task on{" "}
+
+ RooCodeInc/Roo-Code
+
+
+
+
+ Cancel ✕
+
+
+
+ ),
+ },
+ {
+ id: "m6",
+ author: "Roomote",
+ timeLabel: "Monday at 3:10 PM",
+ avatarText: "R",
+ avatarClassName: "bg-violet-500/20 text-violet-200 ring-1 ring-violet-500/30",
+ kind: "bot",
+ body: (
+
+ Cool, I'll knock this out real quick.
+ Follow along
+
+ ),
+ },
+ {
+ id: "m7",
+ author: "Roomote",
+ timeLabel: "Monday at 3:12 PM",
+ avatarText: "R",
+ avatarClassName: "bg-violet-500/20 text-violet-200 ring-1 ring-violet-500/30",
+ kind: "bot",
+ body: (
+
+
Todo List:
+
+
+ {[
+ "Analyze existing page structures and component patterns",
+ "Review marketing content requirements and wireframe details",
+ "Create detailed component architecture plan",
+ "Design page structure and section breakdown",
+ "Plan navigation updates and integration points",
+ "Test the page and verify all sections work",
+ ].map((item) => (
+
+ •
+ {item}
+
+ ))}
+
+
+
(edited)
+
+ ),
+ },
+ {
+ id: "m8",
+ author: "Roomote",
+ timeLabel: "Monday at 3:16 PM",
+ avatarText: "R",
+ avatarClassName: "bg-violet-500/20 text-violet-200 ring-1 ring-violet-500/30",
+ kind: "bot",
+ body: (
+
+
+ I've created a comprehensive implementation plan for the Roo Code Slack integration
+ marketing page at{" "}
+
+ plans/slack-marketing-page-plan.md
+
+ .
+
+
+
Plan Overview
+
+
+ • Hero + dual CTAs
+
+
+ • Value props grid
+
+
+ • “Thread to Shipped Feature” workflow
+
+
+ • Onboarding steps + CTA
+
+
+
+
+ Full document:
+ View artifact
+
+
+ Want to follow up? Just @-mention me in your response.
+
+
+ ),
+ },
+ {
+ id: "m9",
+ author: "Avery Lee",
+ timeLabel: "Monday at 3:17 PM",
+ avatarText: "AL",
+ avatarClassName: "bg-[#2B2D31] text-[#F8F8F9] ring-1 ring-white/10",
+ kind: "human",
+ body: (
+
+ @Roomote
+ this looks great, let's use Coderoo to build this
+
+ ),
+ },
+ {
+ id: "m10",
+ author: "Roomote",
+ timeLabel: "Monday at 3:23 PM",
+ avatarText: "R",
+ avatarClassName: "bg-violet-500/20 text-violet-200 ring-1 ring-violet-500/30",
+ kind: "bot",
+ body: (
+
+
+ I've built the Roo Code Slack integration marketing page. Here's what was
+ implemented:
+
+
+
+
Files
+
+
+ •
+
+ apps/web-roo-code/src/app/slack/page.tsx
+ {" "}
+ — Slack marketing page
+
+
+ •
+
+ apps/web-roo-code/src/lib/constants.ts
+ {" "}
+ — added{" "}
+
+ SLACK_DOCS
+
+
+
+ •
+
+ apps/web-roo-code/src/components/chromes/nav-bar.tsx
+ {" "}
+ — added Slack to Product dropdown
+
+
+
+
+
+
Pull Request
+
+ PR #10853 :{" "}
+
+ https://github.com/RooCodeInc/Roo-Code/pull/10853
+
+
+
+
+
+ The page is accessible at{" "}
+ /slack{" "}
+ and includes navigation links in desktop and mobile.
+
+
+ ),
+ },
+ ],
+ [],
+ )
+
+ type DemoPhase =
+ | { kind: "show"; messageIndex: number }
+ | { kind: "typing"; messageIndex: number }
+ | { kind: "reset" }
+
+ const phases: DemoPhase[] = useMemo(() => {
+ const next: DemoPhase[] = []
+ if (messages.length === 0) return [{ kind: "reset" }]
+
+ next.push({ kind: "typing", messageIndex: 0 })
+ next.push({ kind: "show", messageIndex: 0 })
+ for (let messageIndex = 1; messageIndex < messages.length; messageIndex += 1) {
+ next.push({ kind: "typing", messageIndex })
+ next.push({ kind: "show", messageIndex })
+ }
+ next.push({ kind: "reset" })
+ return next
+ }, [messages])
+
+ const lastShowPhaseIndex = useMemo(() => {
+ let lastIndex = -1
+ for (let idx = 0; idx < phases.length; idx += 1) {
+ if (phases[idx]?.kind === "show") lastIndex = idx
+ }
+ return lastIndex
+ }, [phases])
+
+ useEffect(() => {
+ if (reduceMotion) {
+ setStepIndex(lastShowPhaseIndex >= 0 ? lastShowPhaseIndex : 0)
+ return
+ }
+
+ const active = phases[stepIndex] ?? phases.at(0)
+ const isLastMessageShow = active?.kind === "show" && stepIndex === lastShowPhaseIndex
+ const durationMs = (() => {
+ const base = 2200
+ if (active?.kind === "reset") return 500
+ if (active?.kind === "typing") return 900
+ return isLastMessageShow ? base * 2 : base
+ })()
+
+ const timer = window.setTimeout(() => {
+ setStepIndex((prev) => (prev + 1) % phases.length)
+ }, durationMs)
+
+ return () => window.clearTimeout(timer)
+ }, [lastShowPhaseIndex, phases, reduceMotion, stepIndex])
+
+ const activePhase = phases[stepIndex] ?? phases.at(0) ?? { kind: "reset" }
+
+ function getVisibleCount(phase: DemoPhase): number {
+ if (phase.kind === "reset") return 0
+ if (phase.kind === "typing") return phase.messageIndex
+ return phase.messageIndex + 1
+ }
+
+ const visibleCount = getVisibleCount(activePhase)
+ const visibleMessages = messages.slice(0, visibleCount)
+ const typingTarget = activePhase.kind === "typing" ? messages[activePhase.messageIndex] : undefined
+
+ useEffect(() => {
+ const viewport = scrollViewportRef.current
+ if (!viewport) return
+
+ if (activePhase.kind === "reset" || visibleCount <= 1) {
+ viewport.scrollTo({ top: 0, behavior: "auto" })
+ return
+ }
+
+ viewport.scrollTo({
+ top: viewport.scrollHeight,
+ behavior: reduceMotion ? "auto" : "smooth",
+ })
+ }, [activePhase.kind, reduceMotion, visibleCount])
+
+ return (
+
+
+
+
+
+
+ {visibleMessages.map((message) => (
+
+ ))}
+
+ {typingTarget && (
+
+
+ {typingTarget.avatarText}
+
+
+
+
+ {typingTarget.author}
+
+ {typingTarget.kind === "bot" && (
+
+
+ App
+
+ )}
+ typing…
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ {messages.map((message, idx) => (
+
+ ))}
+
+
+
+
+ )
+}
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/apps/web-roo-code/src/lib/constants.ts b/apps/web-roo-code/src/lib/constants.ts
index fe0137661a..0ef5c92851 100644
--- a/apps/web-roo-code/src/lib/constants.ts
+++ b/apps/web-roo-code/src/lib/constants.ts
@@ -9,6 +9,7 @@ export const EXTERNAL_LINKS = {
BLUESKY: "https://bsky.app/profile/roocode.bsky.social",
YOUTUBE: "https://www.youtube.com/@RooCodeYT",
DOCUMENTATION: "https://docs.roocode.com",
+ SLACK_DOCS: "https://docs.roocode.com/roo-code-cloud/slack-integration",
CAREERS: "https://careers.roocode.com",
ISSUES: "https://github.com/RooCodeInc/Roo-Code/issues",
FEATURE_REQUESTS: "https://github.com/RooCodeInc/Roo-Code/discussions/categories/feature-requests",
@@ -28,6 +29,7 @@ export const EXTERNAL_LINKS = {
CLOUD_APP_SIGNUP: "https://app.roocode.com/sign-up",
CLOUD_APP_SIGNUP_HOME: "https://app.roocode.com/sign-up?redirect_url=/cloud-agents/setup",
CLOUD_APP_SIGNUP_PRO: "https://app.roocode.com/sign-up?redirect_url=/cloud-agents/setup",
+ CLOUD_APP_TEAM_TRIAL: "https://app.roocode.com/checkout/team",
SUPPORT: "mailto:support@roocode.com",
}
diff --git a/locales/zh-TW/CODE_OF_CONDUCT.md b/locales/zh-TW/CODE_OF_CONDUCT.md
index d825759797..30f79a875b 100644
--- a/locales/zh-TW/CODE_OF_CONDUCT.md
+++ b/locales/zh-TW/CODE_OF_CONDUCT.md
@@ -15,7 +15,7 @@
## 我們的承諾
-為了營造開放且友善的環境,我們身為貢獻者與維護者,承諾讓參與本專案及社群的體驗,對每個人都不帶有騷擾,不論其年齡、體型、身心障礙、族裔、性徵、性別認同與表現、經驗程度、教育程度、社經地位、國籍、個人外表、種族、宗教信仰、或性傾向。
+為了營造開放且友善的環境,我們身為貢獻者與維護者,承諾讓參與本專案及社群的體驗,對每個人都不帶有騷擾,不論其年齡、體型、身心障礙、族裔、性徵、性別認同與氣質、經驗程度、教育程度、社經地位、國籍、個人外表、種族、宗教信仰、或性傾向。
## 我們的準則
@@ -32,14 +32,14 @@
- 使用帶有性暗示的言語或影像,以及不受歡迎的性關注或騷擾
- 挑釁、羞辱/貶低他人的評論,以及人身或政治攻擊
- 公開或私下的騷擾行為
-- 未經他人明確許可,公開他人的私人資料,如實體或電子郵件地址
+- 未經他人明確許可,公開他人的私人資訊,如實體地址或電子郵件信箱
- 其他在專業環境中可被合理認定為不恰當的行為
## 我們的責任
-專案維護者有責任釐清可接受行為的標準,並應對任何不可接受的行為採取適當且公平的糾正措施。
+專案維護者有責任釐清可接受行為的標準,並應對任何不可接受的行為採取適當且公平的處置措施。
-專案維護者有權利和責任移除、編輯或拒絕不符合本行為準則的評論、提交、程式碼、維基編輯、議題和其他貢獻,或暫時或永久封鎖任何他們認為有不當、威脅、冒犯或有害行為的貢獻者。
+專案維護者有權利和責任移除、編輯或拒絕不符合本行為準則的留言、Commit、程式碼、Wiki 編輯、Issue 和其他貢獻,或暫時或永久封鎖任何他們認為有不當、威脅、冒犯或有害行為的貢獻者。
## 範疇
@@ -49,7 +49,7 @@
如發生辱罵、騷擾或其他不可接受的行為,請透過 support@roocode.com 聯絡專案團隊回報。所有申訴都將被審查和調查,並做出必要且合適的回應。專案團隊有義務為事件回報者保密。具體執行政策的更多細節可能另行公佈。
-未遵守或未切實執行本行為準則的專案維護者,可能會面臨由專案領導團隊其他成員所決定的暫時或永久的處置。
+未遵守或未切實執行本行為準則的專案維護者,可能會面臨由專案領導團隊其他成員所決定的暫時或永久處分。
## 來源說明
diff --git a/locales/zh-TW/CONTRIBUTING.md b/locales/zh-TW/CONTRIBUTING.md
index 82313d7561..79864fd4f3 100644
--- a/locales/zh-TW/CONTRIBUTING.md
+++ b/locales/zh-TW/CONTRIBUTING.md
@@ -13,7 +13,7 @@
# 為 Roo Code 做出貢獻
-Roo Code 是一個由社群驅動的專案,我們非常重視每一份貢獻。為了簡化協作,我們採用 [「問題優先」的方法](#問題優先方法),這意味著所有的 [拉取請求 (PR)](#提交拉取請求) 都必須先連結到一個 GitHub 問題。請仔細閱讀本指南。
+Roo Code 是一個由社群驅動的專案,我們非常重視每一份貢獻。為了簡化協作流程,我們採用 [「Issue 優先」的方法](#issue-優先方法),這意味著所有的 [Pull Request (PR)](#提交-pull-request) 都必須先連結到一個 GitHub Issue。請仔細閱讀本指南。
## 目錄
@@ -30,32 +30,32 @@ Roo Code 是一個由社群驅動的專案,我們非常重視每一份貢獻
### 2. 專案路線圖
-我們的路線圖指導著專案的方向。請將您的貢獻與這些關鍵目標保持一致:
+我們的路線圖指引著專案的方向。請將您的貢獻與這些關鍵目標保持一致:
-### 可靠性第一
+### 可靠性第一 (Reliability First)
- 確保差異編輯和命令執行始終可靠。
-- 減少阻礙常規使用的摩擦點。
-- 保證在所有地區和平台上的流暢操作。
-- 擴大對各種人工智慧提供商和模型的強大支援。
+- 減少阻礙常規使用的摩擦。
+- 保證在所有語系和平台上的操作流暢。
+- 擴大對各種 AI 供應商和模型的強大支援。
-### 增強的使用者體驗
+### 增強的使用者體驗 (Enhanced User Experience)
-- 簡化使用者介面/使用者體驗,以提高清晰度和直觀性。
-- 不斷改進工作流程,以滿足開發人員對日常使用工具的高期望。
+- 簡化 UI/UX,提高清晰度和直覺性。
+- 持續改進工作流程,以滿足開發者對日常使用工具的高期望。
-### 在代理效能上領先
+### 在 Agent 效能上領先 (Leading on Agent Performance)
-- 建立全面的評估基準 (evals) 來衡量真實世界的生產力。
-- 讓每個人都能輕鬆執行和解釋這些評估。
-- 發布能顯示評估分數明顯提高的改進。
+- 建立全面的評估基準 (Evals) 來衡量實際應用的生產力。
+- 讓每個人都能輕鬆執行和解讀這些評估結果。
+- 發布能顯示評估分數有明顯提升的改進。
在您的 PR 中提及與這些領域的一致性。
### 3. 加入 Roo Code 社群
- **主要方式:** 加入我們的 [Discord](https://discord.gg/roocode) 並私訊 **Hannes Rudolph (`hrudolph`)**。
-- **替代方式:** 經驗豐富的貢獻者可以透過 [GitHub 專案](https://github.com/orgs/RooCodeInc/projects/1) 直接參與。
+- **替代方式:** 經驗豐富的貢獻者可以透過 [GitHub Project](https://github.com/orgs/RooCodeInc/projects/1) 直接參與。
## 尋找和規劃您的貢獻
@@ -65,42 +65,42 @@ Roo Code 是一個由社群驅動的專案,我們非常重視每一份貢獻
- **新功能:** 新增功能。
- **文件:** 改進指南和清晰度。
-### 問題優先方法
+### Issue 優先方法
-所有貢獻都始於使用我們精簡範本的 GitHub 問題。
+所有貢獻都始於使用我們精簡範本的 GitHub Issue。
-- **檢查現有問題**:在 [GitHub 問題](https://github.com/RooCodeInc/Roo-Code/issues) 中搜尋。
-- **使用以下範本建立問題**:
- - **增強功能:** 「增強請求」範本(著重於使用者利益的簡單語言)。
- - **錯誤:** 「錯誤報告」範本(最少的重現步驟 + 預期與實際 + 版本)。
-- **想參與其中嗎?** 在問題上評論“領取”,並在[Discord](https://discord.gg/roocode)上私訊 **Hannes Rudolph (`hrudolph`)** 以獲得分配。分配將在帖子中確認。
-- **PR 必須連結到問題。** 未連結的 PR 可能會被關閉。
+- **檢查現有 Issue**:在 [GitHub Issues](https://github.com/RooCodeInc/Roo-Code/issues) 中搜尋。
+- **使用以下範本建立 Issue**:
+ - **增強功能:** 「Enhancement Request」範本(著重於使用者利益的淺顯描述)。
+ - **錯誤** 「Bug Report」範本(最少的重現步驟 + 預期與實際結果 + 版本)。
+- **想參與其中嗎?** 在 Issue 上留言「Claiming」,並在 [Discord](https://discord.gg/roocode) 上私訊 **Hannes Rudolph (`hrudolph`)** 以獲得分配。分配結果將在討論串中確認。
+- **PR 必須連結到 Issue。** 未連結的 PR 可能會被關閉。
### 決定做什麼
-- 查看 [GitHub 專案](https://github.com/orgs/RooCodeInc/projects/1) 中的「問題 [未分配]」問題。
-- 如需文件,請造訪 [Roo Code 文件](https://github.com/RooCodeInc/Roo-Code-Docs)。
+- 查看 [GitHub 專案](https://github.com/orgs/RooCodeInc/projects/1) 中的「Issue [Unassigned]」。
+- 如需文件,請造訪 [Roo Code Docs](https://github.com/RooCodeInc/Roo-Code-Docs)。
-### 報告錯誤
+### 回報錯誤
- 首先檢查現有的報告。
-- 使用 [「錯誤報告」範本](https://github.com/RooCodeInc/Roo-Code/issues/new/choose) 建立一個新錯誤,並提供:
+- 使用 [「Bug Report」範本](https://github.com/RooCodeInc/Roo-Code/issues/new/choose) 建立一個新的錯誤回報,並提供:
- 清晰、編號的重現步驟
- 預期與實際結果
- - Roo Code 版本(必需);如果相關,還需提供 API 提供商/模型
-- **安全問題**:透過 [安全公告](https://github.com/RooCodeInc/Roo-Code/security/advisories/new) 私下報告。
+ - Roo Code 版本(必填);如果相關,還需提供 API 供應商/模型
+- **安全問題**:透過 [安全公告 (Security Advisories)](https://github.com/RooCodeInc/Roo-Code/security/advisories/new) 私下回報。
## 開發和提交流程
### 開發設定
-1. **複製和克隆:**
+1. **Fork 與 Clone:**
```
-git clone https://github.com/您的使用者名稱/Roo-Code.git
+git clone https://github.com/YOUR_USERNAME/Roo-Code.git
```
-2. **安裝依賴項:**
+2. **安裝相依套件:**
```
pnpm install
@@ -108,34 +108,34 @@ pnpm install
3. **偵錯:** 使用 VS Code 開啟(`F5`)。
-### 編碼指南
+### 程式碼撰寫指南
-- 每個功能或修復一個集中的 PR。
-- 遵循 ESLint 和 TypeScript 的最佳實踐。
-- 編寫清晰、描述性的提交,並引用問題(例如,`修復 #123`)。
+- 每個功能或修復使用一個單一目的的 PR。
+- 遵循 ESLint 和 TypeScript 的最佳實務。
+- 撰寫清晰、描述性的 Commit 訊息,並引用 Issue(例如,`Fixes #123`)。
- 提供全面的測試(`npm test`)。
-- 在提交前變基到最新的 `main` 分支。
+- 在提交前 Rebase 到最新的 `main` 分支。
-### 提交拉取請求
+### 提交 Pull Request
-- 如果希望獲得早期回饋,請以 **草稿 PR** 開始。
-- 遵循拉取請求範本,清晰地描述您的變更。
-- 在 PR 描述/標題中連結問題(例如,“修復 #123”)。
+- 如果希望獲得早期回饋,請以 **Draft PR** 開始。
+- 遵循 Pull Request 範本,清晰地描述您的變更。
+- 在 PR 描述/標題中連結 Issue(例如,「Fixes #123」)。
- 為使用者介面變更提供螢幕截圖/影片。
- 指明是否需要更新文件。
-### 拉取請求政策
+### Pull Request 政策
-- 必須引用一個已分配的 GitHub 問題。要獲得分配:在問題上評論“領取”,並在[Discord](https://discord.gg/roocode)上私訊 **Hannes Rudolph (`hrudolph`)**。分配將在帖子中確認。
-- 未連結的 PR 可能會被關閉。
+- 必須引用一個已指派的 GitHub Issue。如要被指派:請在 Issue 上留言「Claiming」,並在 [Discord](https://discord.gg/roocode) 上私訊 **Hannes Rudolph (`hrudolph`)**。指派結果將可在討論串中確認。
+- 未連結 Issue 的 PR 可能會被關閉。
- PR 必須通過 CI 測試,與路線圖保持一致,並有清晰的文件。
### 審查流程
-- **每日分類:** 維護人員進行快速檢查。
+- **每日 Triage:** 維護者進行快速檢查。
- **每週深入審查:** 全面評估。
- **根據回饋及時迭代**。
-## 法律
+## 法律資訊
-透過貢獻,您同意您的貢獻將根據 Apache 2.0 授權進行授權,這與 Roo Code 的授權一致。
+透過貢獻,您同意您的貢獻將根據 Apache 2.0 授權條款進行授權,這與 Roo Code 的授權一致。
diff --git a/locales/zh-TW/README.md b/locales/zh-TW/README.md
index 35febb2348..76415a7797 100644
--- a/locales/zh-TW/README.md
+++ b/locales/zh-TW/README.md
@@ -6,15 +6,15 @@
- 快速取得協助 → 加入 Discord • 偏好非同步?→ 加入 r/RooCode
+ 快速取得協助 → 加入 Discord • 偏好非同步溝通?→ 加入 r/RooCode
# Roo Code
-> 你的 AI 驅動開發團隊,就在你的編輯器裡
+> 您的 AI 驅動開發團隊,就在您的編輯器中
- 🌐 可用語言
+ 🌐 支援語言
- [English](../../README.md)
- [Català](../ca/README.md)
@@ -51,14 +51,14 @@
## 模式
-Roo Code 適應您的工作方式,而不是相反:
+Roo Code 會配合您的工作方式,而非要您配合它:
-- 程式碼模式:日常編碼、編輯和檔案操作
+- 程式碼模式:日常開發、編輯和檔案操作
- 架構師模式:規劃系統、規格和遷移
- 詢問模式:快速回答、解釋和文件
-- 偵錯模式:追蹤問題、新增日誌、隔離根本原因
+- 偵錯模式:追蹤問題、新增日誌、鎖定根本原因
- 自訂模式:為您的團隊或工作流程建置專門的模式
-- Roomote Control:Roomote Control 讓你能遠端控制在本機 VS Code 執行個體中運行的工作。
+- Roomote Control:Roomote Control 讓您能遠端控制在本機 VS Code 執行個體中運行的工作。
更多資訊:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自訂模式](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
@@ -69,7 +69,7 @@ Roo Code 適應您的工作方式,而不是相反:
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| 安裝 Roo Code | 設定設定檔 | 程式碼庫索引 |
-| 自訂模式 | 檢查點 | 上下文管理 |
+| 自訂模式 | 檢查點 | 上下文管理 |
@@ -82,12 +82,12 @@ Roo Code 適應您的工作方式,而不是相反:
- **[YouTube 頻道](https://youtube.com/@roocodeyt?feature=shared):** 觀看教學和功能實際操作。
- **[Discord 伺服器](https://discord.gg/roocode):** 加入社群以獲得即時協助和討論。
- **[Reddit 社群](https://www.reddit.com/r/RooCode):** 分享您的經驗,看看其他人正在建立什麼。
-- **[GitHub 問題](https://github.com/RooCodeInc/Roo-Code/issues):** 回報錯誤並追蹤開發進度。
+- **[GitHub Issues](https://github.com/RooCodeInc/Roo-Code/issues):** 回報問題並追蹤開發進度。
- **[功能請求](https://github.com/RooCodeInc/Roo-Code/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop):** 有想法嗎?與開發人員分享。
---
-## 本地設定與開發
+## 本機設定與開發
1. **複製**儲存庫:
@@ -95,7 +95,7 @@ Roo Code 適應您的工作方式,而不是相反:
git clone https://github.com/RooCodeInc/Roo-Code.git
```
-2. **安裝依賴套件**:
+2. **安裝相依套件**:
```sh
pnpm install
@@ -107,7 +107,7 @@ pnpm install
### 開發模式(F5)
-對於積極的開發,請使用 VSCode 的內建偵錯功能:
+若要進行開發,請使用 VSCode 的內建偵錯功能:
在 VSCode 中按 `F5`(或前往 **執行** → **開始偵錯**)。這將在執行 Roo Code 擴充功能的新 VSCode 視窗中開啟。
@@ -127,7 +127,7 @@ pnpm install:vsix [-y] [--editor=]
- 詢問要使用的編輯器命令(code/cursor/code-insiders) - 預設為“code”
- 解除安裝任何現有版本的擴充功能。
- 建置最新的 VSIX 套件。
-- 安裝新建立的 VSIX。
+- 安裝新建置的 VSIX。
- 提示您重新啟動 VS Code 以使變更生效。
選項:
@@ -144,7 +144,7 @@ pnpm install:vsix [-y] [--editor=]
pnpm vsix
```
2. 將在 `bin/` 目錄中產生一個 `.vsix` 檔案(例如 `bin/roo-cline-.vsix`)。
-3. 使用 VSCode CLI 手動安裝
+3. 使用 VSCode CLI 手動安裝:
```sh
code --install-extension bin/roo-cline-.vsix
```
@@ -163,7 +163,7 @@ pnpm install:vsix [-y] [--editor=]
## 貢獻
-我們歡迎社群貢獻!請閱讀我們的 [CONTRIBUTING.md](CONTRIBUTING.md) 開始。
+我們歡迎社群貢獻!請從閱讀我們的 [CONTRIBUTING.md](CONTRIBUTING.md) 開始。
---
@@ -173,4 +173,4 @@ pnpm install:vsix [-y] [--editor=]
---
-**享受 Roo Code!** 無論您是將它拴在短繩上還是讓它自主漫遊,我們迫不及待地想看看您會建構什麼。如果您有問題或功能想法,請造訪我們的 [Reddit 社群](https://www.reddit.com/r/RooCode/)或 [Discord](https://discord.gg/roocode)。祝您開發愉快!
+**享受 Roo Code!** 無論您是想嚴格控管它,還是讓它自主運作,我們都迫不及待地想看看您會打造些什麼。如果您有問題或功能想法,請造訪我們的 [Reddit 社群](https://www.reddit.com/r/RooCode/)或 [Discord](https://discord.gg/roocode)。祝您開發愉快!
diff --git a/package.json b/package.json
index ed26ce1734..de8dff751c 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,8 @@
"vsix:nightly": "turbo vsix:nightly --log-order grouped --output-logs new-only",
"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",
@@ -65,7 +67,8 @@
"bluebird": ">=3.7.2",
"glob": ">=11.1.0",
"@types/react": "^18.3.23",
- "@types/react-dom": "^18.3.5"
+ "@types/react-dom": "^18.3.5",
+ "zod": "3.25.76"
}
}
}
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/core/package.json b/packages/core/package.json
index 95c6d793b3..25e6224e8c 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -18,6 +18,7 @@
"@roo-code/types": "workspace:^",
"esbuild": "^0.25.0",
"execa": "^9.5.2",
+ "ignore": "^7.0.3",
"openai": "^5.12.2",
"zod": "^3.25.61"
},
diff --git a/packages/core/src/custom-tools/__tests__/__snapshots__/format-xml.spec.ts.snap b/packages/core/src/custom-tools/__tests__/__snapshots__/format-xml.spec.ts.snap
deleted file mode 100644
index b4503fa925..0000000000
--- a/packages/core/src/custom-tools/__tests__/__snapshots__/format-xml.spec.ts.snap
+++ /dev/null
@@ -1,129 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`XML Protocol snapshots > should generate correct XML description for all fixtures combined 1`] = `
-"# Custom Tools
-
-The following custom tools are available for this mode. Use them in the same way as built-in tools.
-
-## simple
-Description: Simple tool
-Parameters:
-- value: (required) The input value (type: string)
-Usage:
-
-value value here
-
-
-## cached
-Description: Cached tool
-Parameters:
-Usage:
-
-
-
-## legacy
-Description: Legacy tool using args
-Parameters:
-- input: (required) The input string (type: string)
-Usage:
-
- input value here
-
-
-## multi_toolA
-Description: Tool A
-Parameters:
-Usage:
-
-
-
-## multi_toolB
-Description: Tool B
-Parameters:
-Usage:
-
-
-
-## mixed_validTool
-Description: Valid
-Parameters:
-Usage:
-
- "
-`;
-
-exports[`XML Protocol snapshots > should generate correct XML description for cached tool 1`] = `
-"# Custom Tools
-
-The following custom tools are available for this mode. Use them in the same way as built-in tools.
-
-## cached
-Description: Cached tool
-Parameters:
-Usage:
-
- "
-`;
-
-exports[`XML Protocol snapshots > should generate correct XML description for legacy tool (using args) 1`] = `
-"# Custom Tools
-
-The following custom tools are available for this mode. Use them in the same way as built-in tools.
-
-## legacy
-Description: Legacy tool using args
-Parameters:
-- input: (required) The input string (type: string)
-Usage:
-
- input value here
- "
-`;
-
-exports[`XML Protocol snapshots > should generate correct XML description for mixed export tool 1`] = `
-"# Custom Tools
-
-The following custom tools are available for this mode. Use them in the same way as built-in tools.
-
-## mixed_validTool
-Description: Valid
-Parameters:
-Usage:
-
- "
-`;
-
-exports[`XML Protocol snapshots > should generate correct XML description for multi export tools 1`] = `
-"# Custom Tools
-
-The following custom tools are available for this mode. Use them in the same way as built-in tools.
-
-## multi_toolA
-Description: Tool A
-Parameters:
-Usage:
-
-
-
-## multi_toolB
-Description: Tool B
-Parameters:
-Usage:
-
- "
-`;
-
-exports[`XML Protocol snapshots > should generate correct XML description for simple tool 1`] = `
-"# Custom Tools
-
-The following custom tools are available for this mode. Use them in the same way as built-in tools.
-
-## simple
-Description: Simple tool
-Parameters:
-- value: (required) The input value (type: string)
-Usage:
-
-value value here
- "
-`;
diff --git a/packages/core/src/custom-tools/__tests__/format-xml.spec.ts b/packages/core/src/custom-tools/__tests__/format-xml.spec.ts
deleted file mode 100644
index 0be0772347..0000000000
--- a/packages/core/src/custom-tools/__tests__/format-xml.spec.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-// pnpm --filter @roo-code/core test src/custom-tools/__tests__/format-xml.spec.ts
-
-import { type SerializedCustomToolDefinition, parametersSchema as z, defineCustomTool } from "@roo-code/types"
-
-import { serializeCustomTool, serializeCustomTools } from "../serialize.js"
-import { formatXml } from "../format-xml.js"
-
-import simpleTool from "./fixtures/simple.js"
-import cachedTool from "./fixtures/cached.js"
-import legacyTool from "./fixtures/legacy.js"
-import { toolA, toolB } from "./fixtures/multi.js"
-import { validTool as mixedValidTool } from "./fixtures/mixed.js"
-
-const fixtureTools = {
- simple: simpleTool,
- cached: cachedTool,
- legacy: legacyTool,
- multi_toolA: toolA,
- multi_toolB: toolB,
- mixed_validTool: mixedValidTool,
-}
-
-describe("formatXml", () => {
- it("should return empty string for empty tools array", () => {
- expect(formatXml([])).toBe("")
- })
-
- it("should throw for undefined tools", () => {
- expect(() => formatXml(undefined as unknown as SerializedCustomToolDefinition[])).toThrow()
- })
-
- it("should generate description for a single tool without args", () => {
- const tool = defineCustomTool({
- name: "my_tool",
- description: "A simple tool that does something",
- async execute() {
- return "done"
- },
- })
-
- const serialized = serializeCustomTool(tool)
- const result = formatXml([serialized])
-
- expect(result).toContain("# Custom Tools")
- expect(result).toContain("## my_tool")
- expect(result).toContain("Description: A simple tool that does something")
- expect(result).toContain("Parameters: None")
- expect(result).toContain("")
- expect(result).toContain(" ")
- })
-
- it("should generate description for a tool with required args", () => {
- const tool = defineCustomTool({
- name: "greeter",
- description: "Greets a person by name",
- parameters: z.object({
- name: z.string().describe("The name of the person to greet"),
- }),
- async execute({ name }) {
- return `Hello, ${name}!`
- },
- })
-
- const serialized = serializeCustomTool(tool)
- const result = formatXml([serialized])
-
- expect(result).toContain("## greeter")
- expect(result).toContain("Description: Greets a person by name")
- expect(result).toContain("Parameters:")
- expect(result).toContain("- name: (required) The name of the person to greet (type: string)")
- expect(result).toContain("")
- expect(result).toContain("name value here ")
- expect(result).toContain(" ")
- })
-
- it("should generate description for a tool with optional args", () => {
- const tool = defineCustomTool({
- name: "configurable_tool",
- description: "A tool with optional configuration",
- parameters: z.object({
- input: z.string().describe("The input to process"),
- format: z.string().optional().describe("Output format"),
- }),
- async execute({ input, format }) {
- return format ? `${input} (${format})` : input
- },
- })
-
- const serialized = serializeCustomTool(tool)
- const result = formatXml([serialized])
-
- expect(result).toContain("- input: (required) The input to process (type: string)")
- expect(result).toContain("- format: (optional) Output format (type: string)")
- expect(result).toContain(" input value here")
- expect(result).toContain("optional format value ")
- })
-
- it("should generate descriptions for multiple tools", () => {
- const tools = [
- defineCustomTool({
- name: "tool_a",
- description: "First tool",
- async execute() {
- return "a"
- },
- }),
- defineCustomTool({
- name: "tool_b",
- description: "Second tool",
- parameters: z.object({
- value: z.number().describe("A numeric value"),
- }),
- async execute() {
- return "b"
- },
- }),
- ]
-
- const serialized = serializeCustomTools(tools)
- const result = formatXml(serialized)
-
- expect(result).toContain("## tool_a")
- expect(result).toContain("Description: First tool")
- expect(result).toContain("## tool_b")
- expect(result).toContain("Description: Second tool")
- expect(result).toContain("- value: (required) A numeric value (type: number)")
- })
-
- it("should treat args in required array as required", () => {
- // Using a raw SerializedToolDefinition to test the required behavior.
- const tools: SerializedCustomToolDefinition[] = [
- {
- name: "test_tool",
- description: "Test tool",
- parameters: {
- type: "object",
- properties: {
- data: {
- type: "object",
- description: "Some data",
- },
- },
- required: ["data"],
- },
- },
- ]
-
- const result = formatXml(tools)
-
- expect(result).toContain("- data: (required) Some data (type: object)")
- expect(result).toContain("data value here ")
- })
-})
-
-describe("XML Protocol snapshots", () => {
- it("should generate correct XML description for simple tool", () => {
- const serialized = serializeCustomTool(fixtureTools.simple)
- const result = formatXml([serialized])
- expect(result).toMatchSnapshot()
- })
-
- it("should generate correct XML description for cached tool", () => {
- const serialized = serializeCustomTool(fixtureTools.cached)
- const result = formatXml([serialized])
- expect(result).toMatchSnapshot()
- })
-
- it("should generate correct XML description for legacy tool (using args)", () => {
- const serialized = serializeCustomTool(fixtureTools.legacy)
- const result = formatXml([serialized])
- expect(result).toMatchSnapshot()
- })
-
- it("should generate correct XML description for multi export tools", () => {
- const serializedA = serializeCustomTool(fixtureTools.multi_toolA)
- const serializedB = serializeCustomTool(fixtureTools.multi_toolB)
- const result = formatXml([serializedA, serializedB])
- expect(result).toMatchSnapshot()
- })
-
- it("should generate correct XML description for mixed export tool", () => {
- const serialized = serializeCustomTool(fixtureTools.mixed_validTool)
- const result = formatXml([serialized])
- expect(result).toMatchSnapshot()
- })
-
- it("should generate correct XML description for all fixtures combined", () => {
- const allSerialized = Object.values(fixtureTools).map(serializeCustomTool)
- const result = formatXml(allSerialized)
- expect(result).toMatchSnapshot()
- })
-})
diff --git a/packages/core/src/custom-tools/format-xml.ts b/packages/core/src/custom-tools/format-xml.ts
deleted file mode 100644
index 01338f236c..0000000000
--- a/packages/core/src/custom-tools/format-xml.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import type { SerializedCustomToolDefinition, SerializedCustomToolParameters } from "@roo-code/types"
-
-/**
- * Extract the type string from a parameter schema.
- * Handles both direct `type` property and `anyOf` schemas (used for nullable types).
- */
-function getParameterType(parameter: SerializedCustomToolParameters): string {
- // Direct type property
- if (parameter.type) {
- return String(parameter.type)
- }
-
- // Handle anyOf schema (used for nullable types like `string | null`)
- if (parameter.anyOf && Array.isArray(parameter.anyOf)) {
- const types = parameter.anyOf
- .map((schema) => (typeof schema === "object" && schema.type ? String(schema.type) : null))
- .filter((t): t is string => t !== null && t !== "null")
-
- if (types.length > 0) {
- return types.join(" | ")
- }
- }
-
- return "unknown"
-}
-
-function getParameterDescription(name: string, parameter: SerializedCustomToolParameters, required: string[]): string {
- const requiredText = required.includes(name) ? "(required)" : "(optional)"
- const typeText = getParameterType(parameter)
- return `- ${name}: ${requiredText} ${parameter.description ?? ""} (type: ${typeText})`
-}
-
-function getUsage(tool: SerializedCustomToolDefinition): string {
- const lines: string[] = [`<${tool.name}>`]
-
- if (tool.parameters) {
- const required = tool.parameters.required ?? []
-
- for (const [argName, _argType] of Object.entries(tool.parameters.properties ?? {})) {
- const placeholder = required.includes(argName) ? `${argName} value here` : `optional ${argName} value`
- lines.push(`<${argName}>${placeholder}${argName}>`)
- }
- }
-
- lines.push(`${tool.name}>`)
- return lines.join("\n")
-}
-
-function getDescription(tool: SerializedCustomToolDefinition): string {
- const parts: string[] = []
-
- parts.push(`## ${tool.name}`)
- parts.push(`Description: ${tool.description}`)
-
- if (tool.parameters?.properties) {
- const required = tool.parameters?.required ?? []
- parts.push("Parameters:")
-
- for (const [name, parameter] of Object.entries(tool.parameters.properties)) {
- // What should we do with `boolean` values for `parameter`?
- if (typeof parameter !== "object") {
- continue
- }
-
- parts.push(getParameterDescription(name, parameter, required))
- }
- } else {
- parts.push("Parameters: None")
- }
-
- parts.push("Usage:")
- parts.push(getUsage(tool))
-
- return parts.join("\n")
-}
-
-export function formatXml(tools: SerializedCustomToolDefinition[]): string {
- if (tools.length === 0) {
- return ""
- }
-
- const descriptions = tools.map((tool) => getDescription(tool))
-
- return `# Custom Tools
-
-The following custom tools are available for this mode. Use them in the same way as built-in tools.
-
-${descriptions.join("\n\n")}`
-}
diff --git a/packages/core/src/custom-tools/index.ts b/packages/core/src/custom-tools/index.ts
index c8b44ec117..c6ddc0f6eb 100644
--- a/packages/core/src/custom-tools/index.ts
+++ b/packages/core/src/custom-tools/index.ts
@@ -1,4 +1,3 @@
export * from "./custom-tool-registry.js"
export * from "./serialize.js"
-export * from "./format-xml.js"
export * from "./format-native.js"
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 937f71063b..e5b42a0748 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,3 +1,4 @@
export * from "./custom-tools/index.js"
export * from "./debug-log/index.js"
export * from "./message-utils/index.js"
+export * from "./worktree/index.js"
diff --git a/packages/core/src/worktree/__tests__/worktree-include.spec.ts b/packages/core/src/worktree/__tests__/worktree-include.spec.ts
new file mode 100644
index 0000000000..88069b95da
--- /dev/null
+++ b/packages/core/src/worktree/__tests__/worktree-include.spec.ts
@@ -0,0 +1,306 @@
+import * as fs from "fs/promises"
+import * as path from "path"
+import * as os from "os"
+import { execFile } from "child_process"
+import { promisify } from "util"
+
+import { WorktreeIncludeService } from "../worktree-include.js"
+
+const execFileAsync = promisify(execFile)
+
+async function execGit(cwd: string, args: string[]): Promise {
+ const { stdout } = await execFileAsync("git", args, { cwd, encoding: "utf8" })
+ return stdout
+}
+
+describe("WorktreeIncludeService", () => {
+ let service: WorktreeIncludeService
+ let tempDir: string
+
+ beforeEach(async () => {
+ service = new WorktreeIncludeService()
+ // Create a temp directory for each test
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "worktree-test-"))
+ })
+
+ afterEach(async () => {
+ // Clean up temp directory
+ try {
+ await fs.rm(tempDir, { recursive: true })
+ } catch {
+ // Ignore cleanup errors
+ }
+ })
+
+ describe("hasWorktreeInclude", () => {
+ it("should return true when .worktreeinclude exists", async () => {
+ await fs.writeFile(path.join(tempDir, ".worktreeinclude"), "node_modules")
+
+ const result = await service.hasWorktreeInclude(tempDir)
+
+ expect(result).toBe(true)
+ })
+
+ it("should return false when .worktreeinclude does not exist", async () => {
+ const result = await service.hasWorktreeInclude(tempDir)
+
+ expect(result).toBe(false)
+ })
+
+ it("should return false for non-existent directory", async () => {
+ const result = await service.hasWorktreeInclude("/non/existent/path")
+
+ expect(result).toBe(false)
+ })
+ })
+
+ describe("branchHasWorktreeInclude", () => {
+ it("should detect .worktreeinclude on the specified branch", async () => {
+ const repoDir = path.join(tempDir, "repo")
+ await fs.mkdir(repoDir, { recursive: true })
+
+ await execGit(repoDir, ["init"])
+ await execGit(repoDir, ["config", "user.name", "Test User"])
+ await execGit(repoDir, ["config", "user.email", "test@example.com"])
+
+ await fs.writeFile(path.join(repoDir, "README.md"), "test")
+ await execGit(repoDir, ["add", "README.md"])
+ await execGit(repoDir, ["commit", "-m", "init"])
+
+ const baseBranch = (await execGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim()
+
+ expect(await service.branchHasWorktreeInclude(repoDir, baseBranch)).toBe(false)
+
+ await execGit(repoDir, ["checkout", "-b", "with-include"])
+ await fs.writeFile(path.join(repoDir, ".worktreeinclude"), "node_modules")
+ await execGit(repoDir, ["add", ".worktreeinclude"])
+ await execGit(repoDir, ["commit", "-m", "add include"])
+
+ expect(await service.branchHasWorktreeInclude(repoDir, "with-include")).toBe(true)
+ }, 30_000)
+ })
+
+ describe("getStatus", () => {
+ it("should return correct status when both files exist", async () => {
+ const gitignoreContent = "node_modules\n.env\ndist"
+ await fs.writeFile(path.join(tempDir, ".worktreeinclude"), "node_modules")
+ await fs.writeFile(path.join(tempDir, ".gitignore"), gitignoreContent)
+
+ const result = await service.getStatus(tempDir)
+
+ expect(result.exists).toBe(true)
+ expect(result.hasGitignore).toBe(true)
+ expect(result.gitignoreContent).toBe(gitignoreContent)
+ })
+
+ it("should return correct status when only .gitignore exists", async () => {
+ const gitignoreContent = "node_modules\n.env"
+ await fs.writeFile(path.join(tempDir, ".gitignore"), gitignoreContent)
+
+ const result = await service.getStatus(tempDir)
+
+ expect(result.exists).toBe(false)
+ expect(result.hasGitignore).toBe(true)
+ expect(result.gitignoreContent).toBe(gitignoreContent)
+ })
+
+ it("should return correct status when only .worktreeinclude exists", async () => {
+ await fs.writeFile(path.join(tempDir, ".worktreeinclude"), "node_modules")
+
+ const result = await service.getStatus(tempDir)
+
+ expect(result.exists).toBe(true)
+ expect(result.hasGitignore).toBe(false)
+ expect(result.gitignoreContent).toBeUndefined()
+ })
+
+ it("should return correct status when neither file exists", async () => {
+ const result = await service.getStatus(tempDir)
+
+ expect(result.exists).toBe(false)
+ expect(result.hasGitignore).toBe(false)
+ expect(result.gitignoreContent).toBeUndefined()
+ })
+ })
+
+ describe("createWorktreeInclude", () => {
+ it("should create .worktreeinclude file with specified content", async () => {
+ const content = "node_modules\n.env\ndist"
+
+ await service.createWorktreeInclude(tempDir, content)
+
+ const fileContent = await fs.readFile(path.join(tempDir, ".worktreeinclude"), "utf-8")
+ expect(fileContent).toBe(content)
+ })
+
+ it("should overwrite existing .worktreeinclude file", async () => {
+ await fs.writeFile(path.join(tempDir, ".worktreeinclude"), "old content")
+ const newContent = "new content"
+
+ await service.createWorktreeInclude(tempDir, newContent)
+
+ const fileContent = await fs.readFile(path.join(tempDir, ".worktreeinclude"), "utf-8")
+ expect(fileContent).toBe(newContent)
+ })
+ })
+
+ describe("copyWorktreeIncludeFiles", () => {
+ let sourceDir: string
+ let targetDir: string
+
+ beforeEach(async () => {
+ sourceDir = path.join(tempDir, "source")
+ targetDir = path.join(tempDir, "target")
+ await fs.mkdir(sourceDir, { recursive: true })
+ await fs.mkdir(targetDir, { recursive: true })
+ })
+
+ it("should return empty array when no .worktreeinclude exists", async () => {
+ await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules")
+
+ const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)
+
+ expect(result).toEqual([])
+ })
+
+ it("should return empty array when no .gitignore exists", async () => {
+ await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules")
+
+ const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)
+
+ expect(result).toEqual([])
+ })
+
+ it("should return empty array when patterns do not match", async () => {
+ // .worktreeinclude wants node_modules, .gitignore only ignores .env
+ await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules")
+ await fs.writeFile(path.join(sourceDir, ".gitignore"), ".env")
+ await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })
+
+ const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)
+
+ expect(result).toEqual([])
+ })
+
+ it("should copy files that match both patterns", async () => {
+ // Both files include node_modules
+ await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules")
+ await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules")
+ // Create a file in node_modules
+ await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })
+ await fs.writeFile(path.join(sourceDir, "node_modules", "package.json"), '{"name": "test"}')
+
+ const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)
+
+ expect(result).toContain("node_modules")
+ // Verify the file was copied
+ const copiedContent = await fs.readFile(path.join(targetDir, "node_modules", "package.json"), "utf-8")
+ expect(copiedContent).toBe('{"name": "test"}')
+ })
+
+ it("should only copy intersection of patterns", async () => {
+ // .worktreeinclude: node_modules, dist
+ // .gitignore: node_modules, .env
+ // Only node_modules should be copied (intersection)
+ await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules\ndist")
+ await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules\n.env")
+ await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })
+ await fs.mkdir(path.join(sourceDir, "dist"), { recursive: true })
+ await fs.writeFile(path.join(sourceDir, ".env"), "SECRET=123")
+ await fs.writeFile(path.join(sourceDir, "node_modules", "test.txt"), "test")
+ await fs.writeFile(path.join(sourceDir, "dist", "main.js"), "console.log('dist')")
+
+ const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)
+
+ // Only node_modules should be in the result (matches both)
+ expect(result).toContain("node_modules")
+ expect(result).not.toContain("dist") // only in .worktreeinclude
+ expect(result).not.toContain(".env") // only in .gitignore
+
+ // Verify node_modules was copied
+ const nodeModulesExists = await fs
+ .access(path.join(targetDir, "node_modules"))
+ .then(() => true)
+ .catch(() => false)
+ expect(nodeModulesExists).toBe(true)
+
+ // Verify dist was NOT copied
+ const distExists = await fs
+ .access(path.join(targetDir, "dist"))
+ .then(() => true)
+ .catch(() => false)
+ expect(distExists).toBe(false)
+ })
+
+ it("should skip .git directory", async () => {
+ await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), ".git")
+ await fs.writeFile(path.join(sourceDir, ".gitignore"), ".git")
+ await fs.mkdir(path.join(sourceDir, ".git"), { recursive: true })
+ await fs.writeFile(path.join(sourceDir, ".git", "config"), "[core]")
+
+ const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)
+
+ expect(result).not.toContain(".git")
+ })
+
+ it("should copy single files", async () => {
+ await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), ".env.local")
+ await fs.writeFile(path.join(sourceDir, ".gitignore"), ".env.local")
+ await fs.writeFile(path.join(sourceDir, ".env.local"), "LOCAL_VAR=value")
+
+ const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)
+
+ expect(result).toContain(".env.local")
+ const copiedContent = await fs.readFile(path.join(targetDir, ".env.local"), "utf-8")
+ expect(copiedContent).toBe("LOCAL_VAR=value")
+ })
+
+ it("should ignore comment lines in pattern files", async () => {
+ await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "# comment\nnode_modules\n# another comment")
+ await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules")
+ await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })
+
+ const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)
+
+ expect(result).toContain("node_modules")
+ })
+
+ it("should call progress callback with bytesCopied progress", async () => {
+ // Set up files to copy
+ await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules\n.env.local")
+ await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules\n.env.local")
+ await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })
+ await fs.writeFile(path.join(sourceDir, "node_modules", "test.txt"), "test")
+ await fs.writeFile(path.join(sourceDir, ".env.local"), "LOCAL_VAR=value")
+
+ const progressCalls: Array<{ bytesCopied: number; itemName: string }> = []
+ const onProgress = vi.fn((progress: { bytesCopied: number; itemName: string }) => {
+ progressCalls.push({ ...progress })
+ })
+
+ await service.copyWorktreeIncludeFiles(sourceDir, targetDir, onProgress)
+
+ // Should be called multiple times (initial + after each copy)
+ expect(onProgress).toHaveBeenCalled()
+
+ // bytesCopied should increase over time
+ expect(progressCalls.length).toBeGreaterThan(0)
+ const finalCall = progressCalls[progressCalls.length - 1]
+ expect(finalCall?.bytesCopied).toBeGreaterThan(0)
+
+ // Each call should have an item name
+ expect(progressCalls.every((p) => typeof p.itemName === "string")).toBe(true)
+ })
+
+ it("should not fail when progress callback is not provided", async () => {
+ await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules")
+ await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules")
+ await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })
+
+ // Should not throw when no callback is provided
+ const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)
+
+ expect(result).toContain("node_modules")
+ })
+ })
+})
diff --git a/packages/core/src/worktree/__tests__/worktree-service.spec.ts b/packages/core/src/worktree/__tests__/worktree-service.spec.ts
new file mode 100644
index 0000000000..5d0fbb848f
--- /dev/null
+++ b/packages/core/src/worktree/__tests__/worktree-service.spec.ts
@@ -0,0 +1,146 @@
+import * as path from "path"
+
+import { WorktreeService } from "../worktree-service.js"
+
+describe("WorktreeService", () => {
+ describe("normalizePath", () => {
+ let service: WorktreeService
+
+ beforeEach(() => {
+ service = new WorktreeService()
+ })
+
+ // Access private method for testing
+ const callNormalizePath = (service: WorktreeService, p: string): string => {
+ // @ts-expect-error - accessing private method for testing
+ return service.normalizePath(p)
+ }
+
+ it("should normalize paths with trailing slashes", () => {
+ const result = callNormalizePath(service, "/home/user/project/")
+ expect(result).toBe(path.normalize("/home/user/project"))
+ })
+
+ it("should normalize paths with multiple trailing slashes", () => {
+ const result = callNormalizePath(service, "/home/user/project///")
+ // path.normalize already handles multiple slashes
+ expect(result).toBe(path.normalize("/home/user/project"))
+ })
+
+ it("should preserve root path /", () => {
+ // This is a critical test - the old regex would turn "/" into ""
+ // On Windows, path.normalize("/") returns "\", on Unix it returns "/"
+ const result = callNormalizePath(service, "/")
+ expect(result).toBe(path.sep)
+ })
+
+ it("should handle paths without trailing slashes", () => {
+ const result = callNormalizePath(service, "/home/user/project")
+ expect(result).toBe(path.normalize("/home/user/project"))
+ })
+
+ it("should handle relative paths", () => {
+ const result = callNormalizePath(service, "./some/path/")
+ expect(result).toBe(path.normalize("./some/path"))
+ })
+
+ it("should handle empty string", () => {
+ const result = callNormalizePath(service, "")
+ expect(result).toBe(".")
+ })
+
+ it("should handle Windows-style paths on non-Windows", () => {
+ // path.normalize will convert separators appropriately
+ const result = callNormalizePath(service, "C:\\Users\\test\\project")
+ // On Unix, this stays as-is; on Windows it would normalize
+ expect(result).toBeTruthy()
+ })
+ })
+
+ describe("parseWorktreeOutput", () => {
+ let service: WorktreeService
+
+ beforeEach(() => {
+ service = new WorktreeService()
+ })
+
+ // Access private method for testing
+ const callParseWorktreeOutput = (
+ service: WorktreeService,
+ output: string,
+ currentCwd: string,
+ ): ReturnType => {
+ // @ts-expect-error - accessing private method for testing
+ return service.parseWorktreeOutput(output, currentCwd)
+ }
+
+ it("should parse porcelain output correctly", () => {
+ const output = `worktree /home/user/repo
+HEAD abc123def456
+branch refs/heads/main
+
+worktree /home/user/repo-feature
+HEAD def456abc123
+branch refs/heads/feature/test
+`
+ const result = callParseWorktreeOutput(service, output, "/home/user/repo")
+
+ expect(result).toHaveLength(2)
+ expect(result[0]).toMatchObject({
+ path: "/home/user/repo",
+ branch: "main",
+ commitHash: "abc123def456",
+ isCurrent: true,
+ })
+ expect(result[1]).toMatchObject({
+ path: "/home/user/repo-feature",
+ branch: "feature/test",
+ commitHash: "def456abc123",
+ isCurrent: false,
+ })
+ })
+
+ it("should handle detached HEAD worktrees", () => {
+ const output = `worktree /home/user/repo-detached
+HEAD abc123def456
+detached
+`
+ const result = callParseWorktreeOutput(service, output, "/home/user/other")
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toMatchObject({
+ path: "/home/user/repo-detached",
+ isDetached: true,
+ branch: "",
+ })
+ })
+
+ it("should handle locked worktrees", () => {
+ const output = `worktree /home/user/repo-locked
+HEAD abc123def456
+branch refs/heads/locked-branch
+locked some reason here
+`
+ const result = callParseWorktreeOutput(service, output, "/home/user/other")
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toMatchObject({
+ isLocked: true,
+ lockReason: "some reason here",
+ })
+ })
+
+ it("should handle bare worktrees", () => {
+ const output = `worktree /home/user/repo.git
+bare
+`
+ const result = callParseWorktreeOutput(service, output, "/home/user/other")
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toMatchObject({
+ path: "/home/user/repo.git",
+ isBare: true,
+ })
+ })
+ })
+})
diff --git a/packages/core/src/worktree/index.ts b/packages/core/src/worktree/index.ts
new file mode 100644
index 0000000000..ae07ef4aaa
--- /dev/null
+++ b/packages/core/src/worktree/index.ts
@@ -0,0 +1,13 @@
+/**
+ * Worktree Module
+ *
+ * Platform-agnostic git worktree management functionality.
+ * These exports are decoupled from VSCode and can be used by any consumer.
+ */
+
+// Types
+export * from "./types.js"
+
+// Services
+export { WorktreeService, worktreeService } from "./worktree-service.js"
+export { WorktreeIncludeService, worktreeIncludeService, type CopyProgressCallback } from "./worktree-include.js"
diff --git a/packages/core/src/worktree/types.ts b/packages/core/src/worktree/types.ts
new file mode 100644
index 0000000000..ade04111eb
--- /dev/null
+++ b/packages/core/src/worktree/types.ts
@@ -0,0 +1,15 @@
+/**
+ * Worktree Types
+ *
+ * Re-exports platform-agnostic type definitions from @roo-code/types.
+ */
+
+export type {
+ Worktree,
+ WorktreeResult,
+ BranchInfo,
+ CreateWorktreeOptions,
+ WorktreeIncludeStatus,
+ WorktreeListResponse,
+ WorktreeDefaultsResponse,
+} from "@roo-code/types"
diff --git a/packages/core/src/worktree/worktree-include.ts b/packages/core/src/worktree/worktree-include.ts
new file mode 100644
index 0000000000..09156eb28c
--- /dev/null
+++ b/packages/core/src/worktree/worktree-include.ts
@@ -0,0 +1,428 @@
+/**
+ * WorktreeIncludeService
+ *
+ * Platform-agnostic service for handling .worktreeinclude files.
+ * Used to copy untracked files (like node_modules) when creating worktrees.
+ */
+
+import { execFile, spawn } from "child_process"
+import * as fs from "fs/promises"
+import * as path from "path"
+import { promisify } from "util"
+
+import ignore, { type Ignore } from "ignore"
+
+import type { WorktreeIncludeStatus } from "./types.js"
+
+/**
+ * Progress info for copy tracking.
+ * Shows activity without trying to predict total size (which is inaccurate).
+ */
+export interface CopyProgress {
+ /** Current bytes copied */
+ bytesCopied: number
+ /** Name of current item being copied */
+ itemName: string
+}
+
+/**
+ * Callback for reporting copy progress during worktree file copying.
+ */
+export type CopyProgressCallback = (progress: CopyProgress) => void
+
+const execFileAsync = promisify(execFile)
+
+/**
+ * Service for managing .worktreeinclude files and copying files to new worktrees.
+ * All methods are platform-agnostic and don't depend on VSCode APIs.
+ */
+export class WorktreeIncludeService {
+ /**
+ * Check if .worktreeinclude exists in a directory
+ */
+ async hasWorktreeInclude(dir: string): Promise {
+ try {
+ await fs.access(path.join(dir, ".worktreeinclude"))
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ /**
+ * Check if a specific branch has .worktreeinclude file (in git, not local filesystem)
+ * @param cwd - Current working directory (git repo)
+ * @param branch - Branch name to check
+ */
+ async branchHasWorktreeInclude(cwd: string, branch: string): Promise {
+ try {
+ const ref = `${branch}:.worktreeinclude`
+ // Use git cat-file -e to check if the file exists on the branch (without printing contents)
+ await execFileAsync("git", ["cat-file", "-e", "--", ref], { cwd })
+ return true
+ } catch {
+ // File doesn't exist on this branch
+ return false
+ }
+ }
+
+ /**
+ * Get the status of .worktreeinclude and .gitignore
+ */
+ async getStatus(dir: string): Promise {
+ const worktreeIncludePath = path.join(dir, ".worktreeinclude")
+ const gitignorePath = path.join(dir, ".gitignore")
+
+ let exists = false
+ let hasGitignore = false
+ let gitignoreContent: string | undefined
+
+ try {
+ await fs.access(worktreeIncludePath)
+ exists = true
+ } catch {
+ exists = false
+ }
+
+ try {
+ gitignoreContent = await fs.readFile(gitignorePath, "utf-8")
+ hasGitignore = true
+ } catch {
+ hasGitignore = false
+ }
+
+ return {
+ exists,
+ hasGitignore,
+ gitignoreContent,
+ }
+ }
+
+ /**
+ * Create a .worktreeinclude file with the specified content
+ */
+ async createWorktreeInclude(dir: string, content: string): Promise {
+ await fs.writeFile(path.join(dir, ".worktreeinclude"), content, "utf-8")
+ }
+
+ /**
+ * Copy files matching .worktreeinclude patterns from source to target.
+ * Only copies files that are ALSO in .gitignore (to avoid copying tracked files).
+ *
+ * @param sourceDir - The source directory containing the files to copy
+ * @param targetDir - The target directory where files will be copied
+ * @param onProgress - Optional callback to report copy progress (size-based)
+ * @returns Array of copied file/directory paths
+ */
+ async copyWorktreeIncludeFiles(
+ sourceDir: string,
+ targetDir: string,
+ onProgress?: CopyProgressCallback,
+ ): Promise {
+ const worktreeIncludePath = path.join(sourceDir, ".worktreeinclude")
+ const gitignorePath = path.join(sourceDir, ".gitignore")
+
+ // Check if both files exist
+ let hasWorktreeInclude = false
+ let hasGitignore = false
+
+ try {
+ await fs.access(worktreeIncludePath)
+ hasWorktreeInclude = true
+ } catch {
+ hasWorktreeInclude = false
+ }
+
+ try {
+ await fs.access(gitignorePath)
+ hasGitignore = true
+ } catch {
+ hasGitignore = false
+ }
+
+ if (!hasWorktreeInclude || !hasGitignore) {
+ return []
+ }
+
+ // Parse both files
+ const worktreeIncludePatterns = await this.parseIgnoreFile(worktreeIncludePath)
+ const gitignorePatterns = await this.parseIgnoreFile(gitignorePath)
+
+ if (worktreeIncludePatterns.length === 0 || gitignorePatterns.length === 0) {
+ return []
+ }
+
+ // Create ignore matchers
+ const worktreeIncludeMatcher = ignore().add(worktreeIncludePatterns)
+ const gitignoreMatcher = ignore().add(gitignorePatterns)
+
+ // Find items that match BOTH patterns (intersection)
+ const itemsToCopy = await this.findMatchingItems(sourceDir, worktreeIncludeMatcher, gitignoreMatcher)
+
+ if (itemsToCopy.length === 0) {
+ return []
+ }
+
+ let bytesCopied = 0
+
+ // Report initial progress
+ if (onProgress && itemsToCopy.length > 0) {
+ onProgress({ bytesCopied: 0, itemName: itemsToCopy[0]! })
+ }
+
+ // Copy the items with progress tracking (no total size calculation)
+ const copiedItems: string[] = []
+ for (const item of itemsToCopy) {
+ const sourcePath = path.join(sourceDir, item)
+ const targetPath = path.join(targetDir, item)
+
+ try {
+ const stats = await fs.stat(sourcePath)
+
+ if (stats.isDirectory()) {
+ // Copy directory with progress tracking
+ bytesCopied = await this.copyDirectoryWithProgress(
+ sourcePath,
+ targetPath,
+ item,
+ bytesCopied,
+ onProgress,
+ )
+ } else {
+ // Report progress before copying
+ onProgress?.({ bytesCopied, itemName: item })
+
+ // Ensure parent directory exists
+ await fs.mkdir(path.dirname(targetPath), { recursive: true })
+ await fs.copyFile(sourcePath, targetPath)
+
+ // Update bytes copied
+ bytesCopied += this.getSizeOnDisk(stats)
+ }
+
+ copiedItems.push(item)
+
+ // Report progress after copying
+ onProgress?.({ bytesCopied, itemName: item })
+ } catch (error) {
+ // Log but don't fail on individual copy errors
+ console.error(`Failed to copy ${item}:`, error)
+ }
+ }
+
+ return copiedItems
+ }
+
+ /**
+ * Get the size on disk of a file (accounts for filesystem block allocation).
+ * Uses blksize to calculate actual disk usage including block overhead.
+ */
+ private getSizeOnDisk(stats: { size: number; blksize?: number }): number {
+ // Calculate size on disk using filesystem block size
+ if (stats.blksize !== undefined && stats.blksize > 0) {
+ return stats.blksize * Math.ceil(stats.size / stats.blksize)
+ }
+ // Fallback to logical size when blksize not available
+ return stats.size
+ }
+
+ /**
+ * Get the total size on disk of a file or directory (recursively).
+ * Uses native Node.js fs operations for cross-platform compatibility.
+ */
+ private async getPathSize(targetPath: string): Promise {
+ try {
+ const stats = await fs.stat(targetPath)
+
+ if (stats.isFile()) {
+ return this.getSizeOnDisk(stats)
+ }
+
+ if (stats.isDirectory()) {
+ return await this.getDirectorySizeRecursive(targetPath)
+ }
+
+ return 0
+ } catch {
+ return 0
+ }
+ }
+
+ /**
+ * Recursively calculate directory size on disk using Node.js fs.
+ * Uses parallel processing for better performance on large directories.
+ */
+ private async getDirectorySizeRecursive(dirPath: string): Promise {
+ try {
+ const entries = await fs.readdir(dirPath, { withFileTypes: true })
+ const sizes = await Promise.all(
+ entries.map(async (entry) => {
+ const entryPath = path.join(dirPath, entry.name)
+ try {
+ if (entry.isFile()) {
+ const stats = await fs.stat(entryPath)
+ return this.getSizeOnDisk(stats)
+ } else if (entry.isDirectory()) {
+ return await this.getDirectorySizeRecursive(entryPath)
+ }
+ return 0
+ } catch {
+ return 0 // Skip inaccessible files
+ }
+ }),
+ )
+ return sizes.reduce((sum, size) => sum + size, 0)
+ } catch {
+ return 0
+ }
+ }
+
+ /**
+ * Get the current size of a directory (for progress tracking).
+ */
+ private async getCurrentDirectorySize(dirPath: string): Promise {
+ try {
+ await fs.access(dirPath)
+ return await this.getDirectorySizeRecursive(dirPath)
+ } catch {
+ return 0
+ }
+ }
+
+ /**
+ * Copy directory with progress polling using native cp command.
+ * Starts native copy and polls target directory size to report progress.
+ * Returns the updated bytesCopied count.
+ */
+ private async copyDirectoryWithProgress(
+ source: string,
+ target: string,
+ itemName: string,
+ bytesCopiedBefore: number,
+ onProgress?: CopyProgressCallback,
+ ): Promise {
+ // Ensure parent directory exists
+ await fs.mkdir(path.dirname(target), { recursive: true })
+
+ const isWindows = process.platform === "win32"
+
+ // Start the copy process
+ const copyPromise = new Promise((resolve, reject) => {
+ let proc: ReturnType
+
+ if (isWindows) {
+ proc = spawn("robocopy", [source, target, "/E", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP"], {
+ windowsHide: true,
+ })
+ } else {
+ proc = spawn("cp", ["-r", "--", source, target])
+ }
+
+ proc.on("close", (code) => {
+ if (isWindows) {
+ // robocopy returns non-zero for success (values < 8)
+ if (code !== null && code < 8) {
+ resolve()
+ } else {
+ reject(new Error(`robocopy failed with code ${code}`))
+ }
+ } else {
+ if (code === 0) {
+ resolve()
+ } else {
+ reject(new Error(`cp failed with code ${code}`))
+ }
+ }
+ })
+
+ proc.on("error", reject)
+ })
+
+ // Poll progress while copying
+ const pollInterval = 500 // Poll every 500ms
+ let polling = true
+
+ const pollProgress = async () => {
+ while (polling) {
+ const currentSize = await this.getCurrentDirectorySize(target)
+ const totalCopied = bytesCopiedBefore + currentSize
+
+ onProgress?.({
+ bytesCopied: totalCopied,
+ itemName,
+ })
+
+ await new Promise((resolve) => setTimeout(resolve, pollInterval))
+ }
+ }
+
+ // Start polling and wait for copy to complete
+ const pollPromise = pollProgress()
+
+ try {
+ await copyPromise
+ } finally {
+ polling = false
+ // Wait for final poll iteration to complete
+ await pollPromise.catch(() => {})
+ }
+
+ // Get the final size of the copied directory
+ const finalSize = await this.getPathSize(target)
+ return bytesCopiedBefore + finalSize
+ }
+
+ /**
+ * Parse a .gitignore-style file and return the patterns
+ */
+ private async parseIgnoreFile(filePath: string): Promise {
+ try {
+ const content = await fs.readFile(filePath, "utf-8")
+ return content
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith("#"))
+ } catch {
+ return []
+ }
+ }
+
+ /**
+ * Find items in sourceDir that match both matchers
+ */
+ private async findMatchingItems(
+ sourceDir: string,
+ includeMatcher: Ignore,
+ gitignoreMatcher: Ignore,
+ ): Promise {
+ const matchingItems: string[] = []
+
+ try {
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true })
+
+ for (const entry of entries) {
+ const relativePath = entry.name
+
+ // Skip .git directory
+ if (relativePath === ".git") continue
+
+ // Check if this path matches both patterns
+ // For .worktreeinclude, we want items that are "ignored" (matched)
+ // For .gitignore, we want items that are "ignored" (matched)
+ const matchesWorktreeInclude = includeMatcher.ignores(relativePath)
+ const matchesGitignore = gitignoreMatcher.ignores(relativePath)
+
+ if (matchesWorktreeInclude && matchesGitignore) {
+ matchingItems.push(relativePath)
+ }
+ }
+ } catch {
+ return []
+ }
+
+ return matchingItems
+ }
+}
+
+// Export singleton instance for convenience
+export const worktreeIncludeService = new WorktreeIncludeService()
diff --git a/packages/core/src/worktree/worktree-service.ts b/packages/core/src/worktree/worktree-service.ts
new file mode 100644
index 0000000000..86a34292cd
--- /dev/null
+++ b/packages/core/src/worktree/worktree-service.ts
@@ -0,0 +1,315 @@
+/**
+ * WorktreeService
+ *
+ * Platform-agnostic service for git worktree operations.
+ * Uses simple-git and native CLI commands - no VSCode dependencies.
+ */
+
+import { exec, execFile } from "child_process"
+import * as path from "path"
+import { promisify } from "util"
+
+import type { BranchInfo, CreateWorktreeOptions, Worktree, WorktreeResult } from "./types.js"
+
+const execAsync = promisify(exec)
+const execFileAsync = promisify(execFile)
+
+/**
+ * Service for managing git worktrees.
+ * All methods are platform-agnostic and don't depend on VSCode APIs.
+ */
+export class WorktreeService {
+ /**
+ * Check if git is installed on the system
+ */
+ async checkGitInstalled(): Promise {
+ try {
+ await execAsync("git --version")
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ /**
+ * Check if a directory is a git repository.
+ */
+ async checkGitRepo(cwd: string): Promise {
+ try {
+ await execAsync("git rev-parse --git-dir", { cwd })
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ /**
+ * Get the git repository root path.
+ */
+ async getGitRootPath(cwd: string): Promise {
+ try {
+ const { stdout } = await execAsync("git rev-parse --show-toplevel", { cwd })
+ return stdout.trim()
+ } catch {
+ return null
+ }
+ }
+
+ /**
+ * Get the current worktree path.
+ */
+ async getCurrentWorktreePath(cwd: string): Promise {
+ try {
+ const { stdout } = await execAsync("git rev-parse --show-toplevel", { cwd })
+ return stdout.trim()
+ } catch {
+ return null
+ }
+ }
+
+ /**
+ * Get the current branch name.
+ */
+ async getCurrentBranch(cwd: string): Promise {
+ try {
+ const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd })
+ const branch = stdout.trim()
+ return branch === "HEAD" ? null : branch
+ } catch {
+ return null
+ }
+ }
+
+ /**
+ * List all worktrees in the repository
+ */
+ async listWorktrees(cwd: string): Promise {
+ try {
+ const { stdout } = await execAsync("git worktree list --porcelain", { cwd })
+ return this.parseWorktreeOutput(stdout, cwd)
+ } catch {
+ return []
+ }
+ }
+
+ /**
+ * Create a new worktree
+ */
+ async createWorktree(cwd: string, options: CreateWorktreeOptions): Promise {
+ try {
+ const { path: worktreePath, branch, baseBranch, createNewBranch } = options
+
+ // Build the git worktree add command arguments
+ const args: string[] = ["worktree", "add"]
+
+ if (createNewBranch && branch) {
+ // Create new branch: git worktree add -b [ ]
+ args.push("-b", branch, worktreePath)
+ if (baseBranch) {
+ args.push(baseBranch)
+ }
+ } else if (branch) {
+ // Checkout existing branch: git worktree add
+ args.push(worktreePath, branch)
+ } else {
+ // Detached HEAD at current commit
+ args.push("--detach", worktreePath)
+ }
+
+ await execFileAsync("git", args, { cwd })
+
+ // Get the created worktree info
+ const worktrees = await this.listWorktrees(cwd)
+ const createdWorktree = worktrees.find(
+ (wt) => this.normalizePath(wt.path) === this.normalizePath(worktreePath),
+ )
+
+ return {
+ success: true,
+ message: `Worktree created at ${worktreePath}`,
+ worktree: createdWorktree,
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ return {
+ success: false,
+ message: `Failed to create worktree: ${errorMessage}`,
+ }
+ }
+ }
+
+ /**
+ * Delete a worktree
+ */
+ async deleteWorktree(cwd: string, worktreePath: string, force = false): Promise {
+ try {
+ // Get worktree info BEFORE deletion to capture the branch name
+ const worktrees = await this.listWorktrees(cwd)
+ const worktreeToDelete = worktrees.find(
+ (wt) => this.normalizePath(wt.path) === this.normalizePath(worktreePath),
+ )
+
+ const args = ["worktree", "remove"]
+ if (force) {
+ args.push("--force")
+ }
+ args.push(worktreePath)
+ await execFileAsync("git", args, { cwd })
+
+ // Also try to delete the branch if it exists
+ if (worktreeToDelete?.branch) {
+ try {
+ await execFileAsync("git", ["branch", "-d", worktreeToDelete.branch], { cwd })
+ } catch {
+ // Branch deletion is best-effort
+ }
+ }
+
+ return {
+ success: true,
+ message: `Worktree removed from ${worktreePath}`,
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ return {
+ success: false,
+ message: `Failed to delete worktree: ${errorMessage}`,
+ }
+ }
+ }
+
+ /**
+ * Get available branches
+ * @param cwd - Current working directory
+ * @param includeWorktreeBranches - If true, include branches already checked out in worktrees (useful for base branch selection)
+ */
+ async getAvailableBranches(cwd: string, includeWorktreeBranches = false): Promise {
+ try {
+ // Run all git commands in parallel for better performance
+ const [worktrees, localResult, remoteResult, currentBranch] = await Promise.all([
+ this.listWorktrees(cwd),
+ execAsync('git branch --format="%(refname:short)"', { cwd }),
+ execAsync('git branch -r --format="%(refname:short)"', { cwd }),
+ this.getCurrentBranch(cwd),
+ ])
+
+ const branchesInWorktrees = new Set(worktrees.map((wt) => wt.branch).filter(Boolean))
+
+ // Filter local branches
+ const localBranches = localResult.stdout
+ .trim()
+ .split("\n")
+ .filter((b) => b && (includeWorktreeBranches || !branchesInWorktrees.has(b)))
+
+ // Filter remote branches
+ const remoteBranches = remoteResult.stdout
+ .trim()
+ .split("\n")
+ .filter(
+ (b) =>
+ b &&
+ !b.includes("HEAD") &&
+ (includeWorktreeBranches || !branchesInWorktrees.has(b.replace(/^origin\//, ""))),
+ )
+
+ return {
+ localBranches,
+ remoteBranches,
+ currentBranch: currentBranch || "",
+ }
+ } catch {
+ return {
+ localBranches: [],
+ remoteBranches: [],
+ currentBranch: "",
+ }
+ }
+ }
+
+ /**
+ * Checkout a branch in the current worktree
+ */
+ async checkoutBranch(cwd: string, branch: string): Promise {
+ try {
+ await execFileAsync("git", ["checkout", branch], { cwd })
+ return {
+ success: true,
+ message: `Checked out branch ${branch}`,
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ return {
+ success: false,
+ message: `Failed to checkout branch: ${errorMessage}`,
+ }
+ }
+ }
+
+ /**
+ * Parse git worktree list --porcelain output
+ */
+ private parseWorktreeOutput(output: string, currentCwd: string): Worktree[] {
+ const worktrees: Worktree[] = []
+ const entries = output.trim().split("\n\n")
+
+ for (const entry of entries) {
+ if (!entry.trim()) continue
+
+ const lines = entry.trim().split("\n")
+ const worktree: Partial = {
+ path: "",
+ branch: "",
+ commitHash: "",
+ isCurrent: false,
+ isBare: false,
+ isDetached: false,
+ isLocked: false,
+ }
+
+ for (const line of lines) {
+ if (line.startsWith("worktree ")) {
+ worktree.path = line.substring(9).trim()
+ } else if (line.startsWith("HEAD ")) {
+ worktree.commitHash = line.substring(5).trim()
+ } else if (line.startsWith("branch ")) {
+ // branch refs/heads/main -> main
+ const branchRef = line.substring(7).trim()
+ worktree.branch = branchRef.replace(/^refs\/heads\//, "")
+ } else if (line === "bare") {
+ worktree.isBare = true
+ } else if (line === "detached") {
+ worktree.isDetached = true
+ } else if (line === "locked") {
+ worktree.isLocked = true
+ } else if (line.startsWith("locked ")) {
+ worktree.isLocked = true
+ worktree.lockReason = line.substring(7).trim()
+ }
+ }
+
+ if (worktree.path) {
+ worktree.isCurrent = this.normalizePath(worktree.path) === this.normalizePath(currentCwd)
+ worktrees.push(worktree as Worktree)
+ }
+ }
+
+ return worktrees
+ }
+
+ /**
+ * Normalize a path for comparison (handle trailing slashes, etc.)
+ */
+ private normalizePath(p: string): string {
+ // normalize resolves ./.. segments, removes duplicate slashes, and standardizes path separators
+ let normalized = path.normalize(p)
+ // however it doesn't remove trailing slashes
+ // remove trailing slash, except for root paths (handles both / and \)
+ if (normalized.length > 1 && (normalized.endsWith("/") || normalized.endsWith("\\"))) {
+ normalized = normalized.slice(0, -1)
+ }
+ return normalized
+ }
+}
+
+// Export singleton instance for convenience
+export const worktreeService = new WorktreeService()
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 1f1ad79161..031136f8ea 100644
--- a/packages/evals/src/cli/runTaskInCli.ts
+++ b/packages/evals/src/cli/runTaskInCli.ts
@@ -1,4 +1,3 @@
-import * as fs from "fs"
import * as path from "path"
import * as os from "node:os"
@@ -8,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).
@@ -20,7 +19,7 @@ import { mergeToolUsage, waitForSubprocessWithTimeout } from "./utils.js"
*/
export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: RunTaskOptions) => {
const { language, exercise } = task
- const prompt = fs.readFileSync(path.resolve(EVALS_REPO_PATH, `prompts/${language}.md`), "utf-8")
+ const promptSourcePath = path.resolve(EVALS_REPO_PATH, `prompts/${language}.md`)
const workspacePath = path.resolve(EVALS_REPO_PATH, language, exercise)
const ipcSocketPath = path.resolve(os.tmpdir(), `evals-cli-${run.id}-${task.id}.sock`)
@@ -40,32 +39,31 @@ export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: R
"--filter",
"@roo-code/cli",
"start",
- "--yes",
- "--exit-on-complete",
- "--reasoning-effort",
- "disabled",
+ "--prompt-file",
+ promptSourcePath,
"--workspace",
workspacePath,
+ "--yes",
+ "--reasoning-effort",
+ "disabled",
+ "--oneshot",
]
if (run.settings?.mode) {
- cliArgs.push("-M", run.settings.mode)
+ cliArgs.push("--mode", run.settings.mode)
}
if (run.settings?.apiProvider) {
- cliArgs.push("-p", run.settings.apiProvider)
+ cliArgs.push("--provider", run.settings.apiProvider)
}
const modelId = run.settings?.apiModelId || run.settings?.openRouterModelId
if (modelId) {
- cliArgs.push("-m", modelId)
+ cliArgs.push("--model", modelId)
}
- cliArgs.push(prompt)
-
logger.info(`CLI command: pnpm ${cliArgs.join(" ")}`)
-
const subprocess = execa("pnpm", cliArgs, { env, cancelSignal, cwd: process.cwd() })
// Buffer for accumulating streaming output until we have complete lines.
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 49064efa6a..5f2db9f9bd 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/telemetry/src/TelemetryService.ts b/packages/telemetry/src/TelemetryService.ts
index ff94b524f8..8eb1ed0ab6 100644
--- a/packages/telemetry/src/TelemetryService.ts
+++ b/packages/telemetry/src/TelemetryService.ts
@@ -111,8 +111,8 @@ export class TelemetryService {
this.captureEvent(TelemetryEventName.MODE_SWITCH, { taskId, newMode })
}
- public captureToolUsage(taskId: string, tool: string, toolProtocol: string): void {
- this.captureEvent(TelemetryEventName.TOOL_USED, { taskId, tool, toolProtocol })
+ public captureToolUsage(taskId: string, tool: string): void {
+ this.captureEvent(TelemetryEventName.TOOL_USED, { taskId, tool })
}
public captureCheckpointCreated(taskId: string): void {
@@ -127,17 +127,11 @@ export class TelemetryService {
this.captureEvent(TelemetryEventName.CHECKPOINT_RESTORED, { taskId })
}
- public captureContextCondensed(
- taskId: string,
- isAutomaticTrigger: boolean,
- usedCustomPrompt?: boolean,
- usedCustomApiHandler?: boolean,
- ): void {
+ public captureContextCondensed(taskId: string, isAutomaticTrigger: boolean, usedCustomPrompt?: boolean): void {
this.captureEvent(TelemetryEventName.CONTEXT_CONDENSED, {
taskId,
isAutomaticTrigger,
...(usedCustomPrompt !== undefined && { usedCustomPrompt }),
- ...(usedCustomApiHandler !== undefined && { usedCustomApiHandler }),
})
}
diff --git a/packages/types/package.json b/packages/types/package.json
index 09fac8d672..d66d87ac72 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -23,7 +23,7 @@
"clean": "rimraf dist .turbo"
},
"dependencies": {
- "zod": "^3.25.61"
+ "zod": "3.25.76"
},
"devDependencies": {
"@roo-code/config-eslint": "workspace:^",
diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts
index cedf9a3e2f..fc7bee2268 100644
--- a/packages/types/src/__tests__/provider-settings.test.ts
+++ b/packages/types/src/__tests__/provider-settings.test.ts
@@ -7,11 +7,6 @@ describe("getApiProtocol", () => {
expect(getApiProtocol("anthropic", "gpt-4")).toBe("anthropic")
})
- it("should return 'anthropic' for claude-code provider", () => {
- expect(getApiProtocol("claude-code")).toBe("anthropic")
- expect(getApiProtocol("claude-code", "some-model")).toBe("anthropic")
- })
-
it("should return 'anthropic' for bedrock provider", () => {
expect(getApiProtocol("bedrock")).toBe("anthropic")
expect(getApiProtocol("bedrock", "gpt-4")).toBe("anthropic")
diff --git a/packages/types/src/__tests__/skills.test.ts b/packages/types/src/__tests__/skills.test.ts
new file mode 100644
index 0000000000..c215f7cdbe
--- /dev/null
+++ b/packages/types/src/__tests__/skills.test.ts
@@ -0,0 +1,144 @@
+import {
+ validateSkillName,
+ SkillNameValidationError,
+ SKILL_NAME_MIN_LENGTH,
+ SKILL_NAME_MAX_LENGTH,
+ SKILL_NAME_REGEX,
+} from "../skills.js"
+
+describe("validateSkillName", () => {
+ describe("valid names", () => {
+ it("accepts single lowercase word", () => {
+ expect(validateSkillName("myskill")).toEqual({ valid: true })
+ })
+
+ it("accepts lowercase letters and numbers", () => {
+ expect(validateSkillName("skill123")).toEqual({ valid: true })
+ })
+
+ it("accepts hyphenated words", () => {
+ expect(validateSkillName("my-skill")).toEqual({ valid: true })
+ })
+
+ it("accepts multiple hyphenated words", () => {
+ expect(validateSkillName("my-awesome-skill")).toEqual({ valid: true })
+ })
+
+ it("accepts single character", () => {
+ expect(validateSkillName("a")).toEqual({ valid: true })
+ })
+
+ it("accepts single digit", () => {
+ expect(validateSkillName("1")).toEqual({ valid: true })
+ })
+
+ it("accepts maximum length name (64 characters)", () => {
+ const maxLengthName = "a".repeat(SKILL_NAME_MAX_LENGTH)
+ expect(validateSkillName(maxLengthName)).toEqual({ valid: true })
+ })
+ })
+
+ describe("empty or missing names", () => {
+ it("rejects empty string", () => {
+ expect(validateSkillName("")).toEqual({
+ valid: false,
+ error: SkillNameValidationError.Empty,
+ })
+ })
+ })
+
+ describe("names that are too long", () => {
+ it("rejects names longer than 64 characters", () => {
+ const tooLongName = "a".repeat(SKILL_NAME_MAX_LENGTH + 1)
+ expect(validateSkillName(tooLongName)).toEqual({
+ valid: false,
+ error: SkillNameValidationError.TooLong,
+ })
+ })
+ })
+
+ describe("invalid format", () => {
+ it("rejects uppercase letters", () => {
+ expect(validateSkillName("MySkill")).toEqual({
+ valid: false,
+ error: SkillNameValidationError.InvalidFormat,
+ })
+ })
+
+ it("rejects leading hyphen", () => {
+ expect(validateSkillName("-myskill")).toEqual({
+ valid: false,
+ error: SkillNameValidationError.InvalidFormat,
+ })
+ })
+
+ it("rejects trailing hyphen", () => {
+ expect(validateSkillName("myskill-")).toEqual({
+ valid: false,
+ error: SkillNameValidationError.InvalidFormat,
+ })
+ })
+
+ it("rejects consecutive hyphens", () => {
+ expect(validateSkillName("my--skill")).toEqual({
+ valid: false,
+ error: SkillNameValidationError.InvalidFormat,
+ })
+ })
+
+ it("rejects spaces", () => {
+ expect(validateSkillName("my skill")).toEqual({
+ valid: false,
+ error: SkillNameValidationError.InvalidFormat,
+ })
+ })
+
+ it("rejects underscores", () => {
+ expect(validateSkillName("my_skill")).toEqual({
+ valid: false,
+ error: SkillNameValidationError.InvalidFormat,
+ })
+ })
+
+ it("rejects special characters", () => {
+ expect(validateSkillName("my@skill")).toEqual({
+ valid: false,
+ error: SkillNameValidationError.InvalidFormat,
+ })
+ })
+
+ it("rejects dots", () => {
+ expect(validateSkillName("my.skill")).toEqual({
+ valid: false,
+ error: SkillNameValidationError.InvalidFormat,
+ })
+ })
+ })
+})
+
+describe("SKILL_NAME_REGEX", () => {
+ it("matches valid names", () => {
+ expect(SKILL_NAME_REGEX.test("myskill")).toBe(true)
+ expect(SKILL_NAME_REGEX.test("my-skill")).toBe(true)
+ expect(SKILL_NAME_REGEX.test("skill123")).toBe(true)
+ expect(SKILL_NAME_REGEX.test("a1-b2-c3")).toBe(true)
+ })
+
+ it("does not match invalid names", () => {
+ expect(SKILL_NAME_REGEX.test("-start")).toBe(false)
+ expect(SKILL_NAME_REGEX.test("end-")).toBe(false)
+ expect(SKILL_NAME_REGEX.test("double--hyphen")).toBe(false)
+ expect(SKILL_NAME_REGEX.test("UPPER")).toBe(false)
+ expect(SKILL_NAME_REGEX.test("")).toBe(false)
+ })
+})
+
+describe("constants", () => {
+ it("has correct min length", () => {
+ expect(SKILL_NAME_MIN_LENGTH).toBe(1)
+ })
+
+ it("has correct max length", () => {
+ expect(SKILL_NAME_MAX_LENGTH).toBe(64)
+ })
+})
diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts
index cccac017ee..206a5647b3 100644
--- a/packages/types/src/cloud.ts
+++ b/packages/types/src/cloud.ts
@@ -94,14 +94,10 @@ export type OrganizationAllowList = z.infer
export const organizationDefaultSettingsSchema = globalSettingsSchema
.pick({
enableCheckpoints: true,
- fuzzyMatchThreshold: true,
maxOpenTabsContext: true,
- maxReadFileLine: true,
maxWorkspaceFiles: true,
showRooIgnoredFiles: true,
terminalCommandDelay: true,
- terminalCompressProgressBar: true,
- terminalOutputLineLimit: true,
terminalShellIntegrationDisabled: true,
terminalShellIntegrationTimeout: true,
terminalZshClearEolMark: true,
@@ -110,10 +106,8 @@ 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(),
- terminalOutputLineLimit: z.number().int().nonnegative().optional(),
terminalShellIntegrationTimeout: z.number().int().nonnegative().optional(),
}),
)
diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts
index f6f701a25d..d7eb0b03d6 100644
--- a/packages/types/src/experiment.ts
+++ b/packages/types/src/experiment.ts
@@ -6,15 +6,7 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js"
* ExperimentId
*/
-export const experimentIds = [
- "powerSteering",
- "multiFileApplyDiff",
- "preventFocusDisruption",
- "imageGeneration",
- "runSlashCommand",
- "multipleNativeToolCalls",
- "customTools",
-] as const
+export const experimentIds = ["preventFocusDisruption", "imageGeneration", "runSlashCommand", "customTools"] as const
export const experimentIdsSchema = z.enum(experimentIds)
@@ -25,12 +17,9 @@ export type ExperimentId = z.infer
*/
export const experimentsSchema = z.object({
- powerSteering: z.boolean().optional(),
- multiFileApplyDiff: z.boolean().optional(),
preventFocusDisruption: z.boolean().optional(),
imageGeneration: z.boolean().optional(),
runSlashCommand: z.boolean().optional(),
- multipleNativeToolCalls: z.boolean().optional(),
customTools: z.boolean().optional(),
})
diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts
index 0ec74e347a..f71b923d6a 100644
--- a/packages/types/src/global-settings.ts
+++ b/packages/types/src/global-settings.ts
@@ -23,11 +23,40 @@ import { languagesSchema } from "./vscode.js"
export const DEFAULT_WRITE_DELAY_MS = 1000
/**
- * Default terminal output character limit constant.
- * This provides a reasonable default that aligns with typical terminal usage
- * while preventing context window explosions from extremely long lines.
+ * Terminal output preview size options for persisted command output.
+ *
+ * Controls how much command output is kept in memory as a "preview" before
+ * the LLM decides to retrieve more via `read_command_output`. Larger previews
+ * mean more immediate context but consume more of the context window.
+ *
+ * - `small`: 5KB preview - Best for long-running commands with verbose output
+ * - `medium`: 10KB preview - Balanced default for most use cases
+ * - `large`: 20KB preview - Best when commands produce critical info early
+ *
+ * @see OutputInterceptor - Uses this setting to determine when to spill to disk
+ * @see PersistedCommandOutput - Contains the resulting preview and artifact reference
*/
-export const DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT = 50_000
+export type TerminalOutputPreviewSize = "small" | "medium" | "large"
+
+/**
+ * Byte limits for each terminal output preview size.
+ *
+ * Maps preview size names to their corresponding byte thresholds.
+ * When command output exceeds these thresholds, the excess is persisted
+ * to disk and made available via the `read_command_output` tool.
+ */
+export const TERMINAL_PREVIEW_BYTES: Record = {
+ small: 5 * 1024, // 5KB
+ medium: 10 * 1024, // 10KB
+ large: 20 * 1024, // 20KB
+}
+
+/**
+ * Default terminal output preview size.
+ * The "medium" (10KB) setting provides a good balance between immediate
+ * visibility and context window conservation for most use cases.
+ */
+export const DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE: TerminalOutputPreviewSize = "medium"
/**
* Minimum checkpoint timeout in seconds.
@@ -63,7 +92,6 @@ export const globalSettingsSchema = z.object({
openRouterImageApiKey: z.string().optional(),
openRouterImageGenerationSelectedModel: z.string().optional(),
- condensingApiConfigId: z.string().optional(),
customCondensingPrompt: z.string().optional(),
autoApprovalEnabled: z.boolean().optional(),
@@ -90,7 +118,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
@@ -144,12 +171,10 @@ 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(),
- terminalOutputLineLimit: z.number().optional(),
- terminalOutputCharacterLimit: z.number().optional(),
+ terminalOutputPreviewSize: z.enum(["small", "medium", "large"]).optional(),
terminalShellIntegrationTimeout: z.number().optional(),
terminalShellIntegrationDisabled: z.boolean().optional(),
terminalCommandDelay: z.number().optional(),
@@ -158,13 +183,10 @@ export const globalSettingsSchema = z.object({
terminalZshOhMy: z.boolean().optional(),
terminalZshP10k: z.boolean().optional(),
terminalZdotdir: z.boolean().optional(),
- terminalCompressProgressBar: z.boolean().optional(),
diagnosticsEnabled: z.boolean().optional(),
rateLimitSeconds: z.number().optional(),
- diffEnabled: z.boolean().optional(),
- fuzzyMatchThreshold: z.number().optional(),
experiments: experimentsSchema.optional(),
codebaseIndexModels: codebaseIndexModelsSchema.optional(),
@@ -175,7 +197,6 @@ export const globalSettingsSchema = z.object({
telemetrySetting: telemetrySettingsSchema.optional(),
mcpEnabled: z.boolean().optional(),
- enableMcpServerCreation: z.boolean().optional(),
mode: z.string().optional(),
modeApiConfigs: z.record(z.string(), z.string()).optional(),
@@ -197,12 +218,26 @@ export const globalSettingsSchema = z.object({
hasOpenedModeSelector: z.boolean().optional(),
lastModeExportPath: z.string().optional(),
lastModeImportPath: z.string().optional(),
+ lastSettingsExportPath: z.string().optional(),
+ lastTaskExportPath: z.string().optional(),
+ lastImageSavePath: z.string().optional(),
/**
* Whether to show multiple questions one by one or all at once.
* @default false (all at once)
*/
showQuestionsOneByOne: z.boolean().optional(),
+
+ /**
+ * Path to worktree to auto-open after switching workspaces.
+ * Used by the worktree feature to open the Roo Code sidebar in a new window.
+ */
+ worktreeAutoOpenPath: z.string().optional(),
+ /**
+ * Whether to show the worktree selector in the home screen.
+ * @default true
+ */
+ showWorktreesInHomeScreen: z.boolean().optional(),
})
export type GlobalSettings = z.infer
@@ -333,8 +368,6 @@ export const EVALS_SETTINGS: RooCodeSettings = {
soundEnabled: false,
soundVolume: 0.5,
- terminalOutputLineLimit: 500,
- terminalOutputCharacterLimit: DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
terminalShellIntegrationTimeout: 30000,
terminalCommandDelay: 0,
terminalPowershellCounter: false,
@@ -342,14 +375,10 @@ export const EVALS_SETTINGS: RooCodeSettings = {
terminalZshClearEolMark: true,
terminalZshP10k: false,
terminalZdotdir: true,
- terminalCompressProgressBar: true,
terminalShellIntegrationDisabled: true,
diagnosticsEnabled: true,
- diffEnabled: true,
- fuzzyMatchThreshold: 1,
-
enableCheckpoints: false,
rateLimitSeconds: 0,
@@ -357,7 +386,6 @@ export const EVALS_SETTINGS: RooCodeSettings = {
maxWorkspaceFiles: 200,
maxGitStatusFiles: 20,
showRooIgnoredFiles: true,
- maxReadFileLine: -1, // -1 to enable full file reading.
includeDiagnosticMessages: true,
maxDiagnosticMessages: 50,
diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts
index b4d84cb9a5..a60d1a75b6 100644
--- a/packages/types/src/history.ts
+++ b/packages/types/src/history.ts
@@ -19,16 +19,6 @@ export const historyItemSchema = z.object({
size: z.number().optional(),
workspace: z.string().optional(),
mode: z.string().optional(),
- /**
- * The tool protocol used by this task. Once a task uses tools with a specific
- * protocol (XML or Native), it is permanently locked to that protocol.
- *
- * - "xml": Tool calls are parsed from XML text (no tool IDs)
- * - "native": Tool calls come as tool_call chunks with IDs
- *
- * This ensures task resumption works correctly even when NTC settings change.
- */
- toolProtocol: z.enum(["xml", "native"]).optional(),
apiConfigName: z.string().optional(), // Provider profile name for sticky profile feature
status: z.enum(["active", "completed", "delegated"]).optional(),
delegatedToId: z.string().optional(), // Last child this parent delegated to
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 2ed3b00ac9..ad012b3761 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -19,6 +19,7 @@ export * from "./message.js"
export * from "./mode.js"
export * from "./model.js"
export * from "./provider-settings.js"
+export * from "./skills.js"
export * from "./task.js"
export * from "./todo.js"
export * from "./telemetry.js"
@@ -28,5 +29,6 @@ export * from "./tool-params.js"
export * from "./type-fu.js"
export * from "./vscode-extension-host.js"
export * from "./vscode.js"
+export * from "./worktree.js"
export * from "./providers/index.js"
diff --git a/packages/types/src/mcp.ts b/packages/types/src/mcp.ts
index 92e238efbb..f1bfde325d 100644
--- a/packages/types/src/mcp.ts
+++ b/packages/types/src/mcp.ts
@@ -1,5 +1,11 @@
import { z } from "zod"
+/**
+ * Maximum number of MCP tools that can be enabled before showing a warning.
+ * LLMs tend to perform poorly when given too many tools to choose from.
+ */
+export const MAX_MCP_TOOLS_THRESHOLD = 60
+
/**
* McpServerUse
*/
@@ -128,3 +134,53 @@ export type McpErrorEntry = {
timestamp: number
level: "error" | "warn" | "info"
}
+
+/**
+ * Result of counting enabled MCP tools across servers.
+ */
+export interface EnabledMcpToolsCount {
+ /** Number of enabled and connected MCP servers */
+ enabledServerCount: number
+ /** Total number of enabled tools across all enabled servers */
+ enabledToolCount: number
+}
+
+/**
+ * Count the number of enabled MCP tools across all enabled and connected servers.
+ * This is a pure function that can be used in both backend and frontend contexts.
+ *
+ * @param servers - Array of MCP server objects
+ * @returns Object with enabledToolCount and enabledServerCount
+ *
+ * @example
+ * const { enabledToolCount, enabledServerCount } = countEnabledMcpTools(mcpServers)
+ * if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) {
+ * // Show warning
+ * }
+ */
+export function countEnabledMcpTools(servers: McpServer[]): EnabledMcpToolsCount {
+ let serverCount = 0
+ let toolCount = 0
+
+ for (const server of servers) {
+ // Skip disabled servers
+ if (server.disabled) continue
+
+ // Skip servers that are not connected
+ if (server.status !== "connected") continue
+
+ serverCount++
+
+ // Count enabled tools on this server
+ if (server.tools) {
+ for (const tool of server.tools) {
+ // Tool is enabled if enabledForPrompt is undefined (default) or true
+ if (tool.enabledForPrompt !== false) {
+ toolCount++
+ }
+ }
+ }
+ }
+
+ return { enabledToolCount: toolCount, enabledServerCount: serverCount }
+}
diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts
index 109cd842ba..a725cb094d 100644
--- a/packages/types/src/message.ts
+++ b/packages/types/src/message.ts
@@ -149,6 +149,7 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk {
* - `condense_context`: Context condensation/summarization has started
* - `condense_context_error`: Error occurred during context condensation
* - `codebase_search_result`: Results from searching the codebase
+ * - `too_many_tools_warning`: Warning that too many MCP tools are enabled, which may confuse the LLM
*/
export const clineSays = [
"error",
@@ -180,6 +181,8 @@ export const clineSays = [
"sliding_window_truncation",
"codebase_search_result",
"user_edit_todos",
+ "too_many_tools_warning",
+ "tool",
] as const
export const clineSaySchema = z.enum(clineSays)
diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts
index 21d36bca85..95e9095a89 100644
--- a/packages/types/src/model.ts
+++ b/packages/types/src/model.ts
@@ -110,10 +110,6 @@ export const modelInfoSchema = z.object({
isStealthModel: z.boolean().optional(),
// Flag to indicate if the model is free (no cost)
isFree: z.boolean().optional(),
- // Flag to indicate if the model supports native tool calling (OpenAI-style function calling)
- supportsNativeTools: z.boolean().optional(),
- // Default tool protocol preferred by this model (if not specified, falls back to capability/provider defaults)
- defaultToolProtocol: z.enum(["xml", "native"]).optional(),
// Exclude specific native tools from being available (only applies to native protocol)
// These tools will be removed from the set of tools available to the model
excludedTools: z.array(z.string()).optional(),
diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts
index 457252e7fe..0c5965f7ff 100644
--- a/packages/types/src/provider-settings.ts
+++ b/packages/types/src/provider-settings.ts
@@ -7,7 +7,6 @@ import {
basetenModels,
bedrockModels,
cerebrasModels,
- claudeCodeModels,
deepSeekModels,
doubaoModels,
featherlessModels,
@@ -123,7 +122,6 @@ export const providerNames = [
"bedrock",
"baseten",
"cerebras",
- "claude-code",
"doubao",
"deepseek",
"featherless",
@@ -170,9 +168,7 @@ export type ProviderSettingsEntry = z.infer
const baseProviderSettingsSchema = z.object({
includeMaxTokens: z.boolean().optional(),
- diffEnabled: z.boolean().optional(),
todoListEnabled: z.boolean().optional(),
- fuzzyMatchThreshold: z.number().optional(),
modelTemperature: z.number().nullish(),
rateLimitSeconds: z.number().optional(),
consecutiveMistakeLimit: z.number().min(0).optional(),
@@ -185,9 +181,6 @@ const baseProviderSettingsSchema = z.object({
// Model verbosity.
verbosity: verbosityLevelsSchema.optional(),
-
- // Tool protocol override for this profile.
- toolProtocol: z.enum(["xml", "native"]).optional(),
})
// Several of the providers share common model config properties.
@@ -202,8 +195,6 @@ const anthropicSchema = apiModelIdProviderModelSchema.extend({
anthropicBeta1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window.
})
-const claudeCodeSchema = apiModelIdProviderModelSchema.extend({})
-
const openRouterSchema = baseProviderSettingsSchema.extend({
openRouterApiKey: z.string().optional(),
openRouterModelId: z.string().optional(),
@@ -432,7 +423,6 @@ const defaultSchema = z.object({
export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [
anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })),
- claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })),
openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })),
bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })),
vertexSchema.merge(z.object({ apiProvider: z.literal("vertex") })),
@@ -474,7 +464,6 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
export const providerSettingsSchema = z.object({
apiProvider: providerNamesSchema.optional(),
...anthropicSchema.shape,
- ...claudeCodeSchema.shape,
...openRouterSchema.shape,
...bedrockSchema.shape,
...vertexSchema.shape,
@@ -563,7 +552,6 @@ export const isTypicalProvider = (key: unknown): key is TypicalProvider =>
export const modelIdKeysByProvider: Record = {
anthropic: "apiModelId",
- "claude-code": "apiModelId",
openrouter: "openRouterModelId",
bedrock: "apiModelId",
vertex: "apiModelId",
@@ -603,7 +591,7 @@ export const modelIdKeysByProvider: Record = {
*/
// Providers that use Anthropic-style API protocol.
-export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "claude-code", "bedrock", "minimax"]
+export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "bedrock", "minimax"]
export const getApiProtocol = (provider: ProviderName | undefined, modelId?: string): "anthropic" | "openai" => {
if (provider && ANTHROPIC_STYLE_PROVIDERS.includes(provider)) {
@@ -650,7 +638,6 @@ export const MODELS_BY_PROVIDER: Record<
label: "Cerebras",
models: Object.keys(cerebrasModels),
},
- "claude-code": { id: "claude-code", label: "Claude Code", models: Object.keys(claudeCodeModels) },
deepseek: {
id: "deepseek",
label: "DeepSeek",
diff --git a/packages/types/src/providers/__tests__/claude-code.spec.ts b/packages/types/src/providers/__tests__/claude-code.spec.ts
deleted file mode 100644
index 5ed66209a5..0000000000
--- a/packages/types/src/providers/__tests__/claude-code.spec.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { normalizeClaudeCodeModelId } from "../claude-code.js"
-
-describe("normalizeClaudeCodeModelId", () => {
- test("should return valid model IDs unchanged", () => {
- expect(normalizeClaudeCodeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4-5")
- expect(normalizeClaudeCodeModelId("claude-opus-4-5")).toBe("claude-opus-4-5")
- expect(normalizeClaudeCodeModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5")
- })
-
- test("should normalize sonnet models with date suffix to claude-sonnet-4-5", () => {
- // Sonnet 4.5 with date
- expect(normalizeClaudeCodeModelId("claude-sonnet-4-5-20250929")).toBe("claude-sonnet-4-5")
- // Sonnet 4 (legacy)
- expect(normalizeClaudeCodeModelId("claude-sonnet-4-20250514")).toBe("claude-sonnet-4-5")
- // Claude 3.7 Sonnet
- expect(normalizeClaudeCodeModelId("claude-3-7-sonnet-20250219")).toBe("claude-sonnet-4-5")
- // Claude 3.5 Sonnet
- expect(normalizeClaudeCodeModelId("claude-3-5-sonnet-20241022")).toBe("claude-sonnet-4-5")
- })
-
- test("should normalize opus models with date suffix to claude-opus-4-5", () => {
- // Opus 4.5 with date
- expect(normalizeClaudeCodeModelId("claude-opus-4-5-20251101")).toBe("claude-opus-4-5")
- // Opus 4.1 (legacy)
- expect(normalizeClaudeCodeModelId("claude-opus-4-1-20250805")).toBe("claude-opus-4-5")
- // Opus 4 (legacy)
- expect(normalizeClaudeCodeModelId("claude-opus-4-20250514")).toBe("claude-opus-4-5")
- })
-
- test("should normalize haiku models with date suffix to claude-haiku-4-5", () => {
- // Haiku 4.5 with date
- expect(normalizeClaudeCodeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5")
- // Claude 3.5 Haiku
- expect(normalizeClaudeCodeModelId("claude-3-5-haiku-20241022")).toBe("claude-haiku-4-5")
- })
-
- test("should handle case-insensitive model family matching", () => {
- expect(normalizeClaudeCodeModelId("Claude-Sonnet-4-5-20250929")).toBe("claude-sonnet-4-5")
- expect(normalizeClaudeCodeModelId("CLAUDE-OPUS-4-5-20251101")).toBe("claude-opus-4-5")
- })
-
- test("should fallback to default for unrecognized models", () => {
- expect(normalizeClaudeCodeModelId("unknown-model")).toBe("claude-sonnet-4-5")
- expect(normalizeClaudeCodeModelId("gpt-4")).toBe("claude-sonnet-4-5")
- })
-})
diff --git a/packages/types/src/providers/anthropic.ts b/packages/types/src/providers/anthropic.ts
index 70f880a24e..883b6eb716 100644
--- a/packages/types/src/providers/anthropic.ts
+++ b/packages/types/src/providers/anthropic.ts
@@ -11,8 +11,6 @@ export const anthropicModels = {
contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07'
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0, // $3 per million input tokens (≤200K context)
outputPrice: 15.0, // $15 per million output tokens (≤200K context)
cacheWritesPrice: 3.75, // $3.75 per million tokens
@@ -34,8 +32,6 @@ export const anthropicModels = {
contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07'
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0, // $3 per million input tokens (≤200K context)
outputPrice: 15.0, // $15 per million output tokens (≤200K context)
cacheWritesPrice: 3.75, // $3.75 per million tokens
@@ -57,8 +53,6 @@ export const anthropicModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 5.0, // $5 per million input tokens
outputPrice: 25.0, // $25 per million output tokens
cacheWritesPrice: 6.25, // $6.25 per million tokens
@@ -70,8 +64,6 @@ export const anthropicModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 15.0, // $15 per million input tokens
outputPrice: 75.0, // $75 per million output tokens
cacheWritesPrice: 18.75, // $18.75 per million tokens
@@ -83,8 +75,6 @@ export const anthropicModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 15.0, // $15 per million input tokens
outputPrice: 75.0, // $75 per million output tokens
cacheWritesPrice: 18.75, // $18.75 per million tokens
@@ -96,8 +86,6 @@ export const anthropicModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0, // $3 per million input tokens
outputPrice: 15.0, // $15 per million output tokens
cacheWritesPrice: 3.75, // $3.75 per million tokens
@@ -110,8 +98,6 @@ export const anthropicModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0, // $3 per million input tokens
outputPrice: 15.0, // $15 per million output tokens
cacheWritesPrice: 3.75, // $3.75 per million tokens
@@ -122,8 +108,6 @@ export const anthropicModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0, // $3 per million input tokens
outputPrice: 15.0, // $15 per million output tokens
cacheWritesPrice: 3.75, // $3.75 per million tokens
@@ -134,8 +118,6 @@ export const anthropicModels = {
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 1.0,
outputPrice: 5.0,
cacheWritesPrice: 1.25,
@@ -146,8 +128,6 @@ export const anthropicModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 15.0,
outputPrice: 75.0,
cacheWritesPrice: 18.75,
@@ -158,8 +138,6 @@ export const anthropicModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.25,
outputPrice: 1.25,
cacheWritesPrice: 0.3,
@@ -170,8 +148,6 @@ export const anthropicModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 1.0,
outputPrice: 5.0,
cacheWritesPrice: 1.25,
diff --git a/packages/types/src/providers/baseten.ts b/packages/types/src/providers/baseten.ts
index eeb6b0d2d1..27b8cbff4a 100644
--- a/packages/types/src/providers/baseten.ts
+++ b/packages/types/src/providers/baseten.ts
@@ -9,7 +9,6 @@ export const basetenModels = {
contextWindow: 262_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.6,
outputPrice: 2.5,
cacheWritesPrice: 0,
@@ -21,7 +20,6 @@ export const basetenModels = {
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.6,
outputPrice: 2.2,
cacheWritesPrice: 0,
@@ -33,7 +31,6 @@ export const basetenModels = {
contextWindow: 163_840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 2.55,
outputPrice: 5.95,
cacheWritesPrice: 0,
@@ -45,7 +42,6 @@ export const basetenModels = {
contextWindow: 163_840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 2.55,
outputPrice: 5.95,
cacheWritesPrice: 0,
@@ -57,7 +53,6 @@ export const basetenModels = {
contextWindow: 163_840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.77,
outputPrice: 0.77,
cacheWritesPrice: 0,
@@ -69,7 +64,6 @@ export const basetenModels = {
contextWindow: 163_840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.5,
outputPrice: 1.5,
cacheWritesPrice: 0,
@@ -82,7 +76,6 @@ export const basetenModels = {
contextWindow: 163_840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.3,
outputPrice: 0.45,
cacheWritesPrice: 0,
@@ -95,7 +88,6 @@ export const basetenModels = {
contextWindow: 128_072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.1,
outputPrice: 0.5,
cacheWritesPrice: 0,
@@ -107,7 +99,6 @@ export const basetenModels = {
contextWindow: 262_144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.22,
outputPrice: 0.8,
cacheWritesPrice: 0,
@@ -119,7 +110,6 @@ export const basetenModels = {
contextWindow: 262_144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.38,
outputPrice: 1.53,
cacheWritesPrice: 0,
@@ -131,7 +121,6 @@ export const basetenModels = {
contextWindow: 262_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.6,
outputPrice: 2.5,
cacheWritesPrice: 0,
diff --git a/packages/types/src/providers/bedrock.ts b/packages/types/src/providers/bedrock.ts
index 19dfbf0b30..1a95cf33c5 100644
--- a/packages/types/src/providers/bedrock.ts
+++ b/packages/types/src/providers/bedrock.ts
@@ -19,8 +19,6 @@ export const bedrockModels = {
supportsImages: true,
supportsPromptCache: true,
supportsReasoningBudget: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
@@ -34,7 +32,6 @@ export const bedrockModels = {
contextWindow: 300_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 0.8,
outputPrice: 3.2,
cacheWritesPrice: 0.8, // per million tokens
@@ -48,7 +45,6 @@ export const bedrockModels = {
contextWindow: 300_000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 1.0,
outputPrice: 4.0,
cacheWritesPrice: 1.0, // per million tokens
@@ -60,7 +56,6 @@ export const bedrockModels = {
contextWindow: 300_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 0.06,
outputPrice: 0.24,
cacheWritesPrice: 0.06, // per million tokens
@@ -74,7 +69,6 @@ export const bedrockModels = {
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 0.33,
outputPrice: 2.75,
cacheWritesPrice: 0,
@@ -89,7 +83,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 0.035,
outputPrice: 0.14,
cacheWritesPrice: 0.035, // per million tokens
@@ -104,8 +97,6 @@ export const bedrockModels = {
supportsImages: true,
supportsPromptCache: true,
supportsReasoningBudget: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
@@ -120,8 +111,6 @@ export const bedrockModels = {
supportsImages: true,
supportsPromptCache: true,
supportsReasoningBudget: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 15.0,
outputPrice: 75.0,
cacheWritesPrice: 18.75,
@@ -136,8 +125,6 @@ export const bedrockModels = {
supportsImages: true,
supportsPromptCache: true,
supportsReasoningBudget: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
@@ -152,8 +139,6 @@ export const bedrockModels = {
supportsImages: true,
supportsPromptCache: true,
supportsReasoningBudget: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 15.0,
outputPrice: 75.0,
cacheWritesPrice: 18.75,
@@ -168,8 +153,6 @@ export const bedrockModels = {
supportsImages: true,
supportsPromptCache: true,
supportsReasoningBudget: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
@@ -183,8 +166,6 @@ export const bedrockModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
@@ -198,8 +179,6 @@ export const bedrockModels = {
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.8,
outputPrice: 4.0,
cacheWritesPrice: 1.0,
@@ -214,8 +193,6 @@ export const bedrockModels = {
supportsImages: true,
supportsPromptCache: true,
supportsReasoningBudget: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 1.0,
outputPrice: 5.0,
cacheWritesPrice: 1.25, // 5m cache writes
@@ -229,8 +206,6 @@ export const bedrockModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
},
@@ -239,8 +214,6 @@ export const bedrockModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 15.0,
outputPrice: 75.0,
},
@@ -249,8 +222,6 @@ export const bedrockModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
},
@@ -259,8 +230,6 @@ export const bedrockModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.25,
outputPrice: 1.25,
},
@@ -269,7 +238,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 1.35,
outputPrice: 5.4,
},
@@ -278,7 +246,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.5,
outputPrice: 1.5,
description: "GPT-OSS 20B - Optimized for low latency and local/specialized use cases",
@@ -288,7 +255,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 2.0,
outputPrice: 6.0,
description: "GPT-OSS 120B - Production-ready, general-purpose, high-reasoning model",
@@ -298,7 +264,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.72,
outputPrice: 0.72,
description: "Llama 3.3 Instruct (70B)",
@@ -308,7 +273,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.72,
outputPrice: 0.72,
description: "Llama 3.2 Instruct (90B)",
@@ -318,7 +282,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.16,
outputPrice: 0.16,
description: "Llama 3.2 Instruct (11B)",
@@ -328,7 +291,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.15,
outputPrice: 0.15,
description: "Llama 3.2 Instruct (3B)",
@@ -338,7 +300,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.1,
outputPrice: 0.1,
description: "Llama 3.2 Instruct (1B)",
@@ -348,7 +309,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 2.4,
outputPrice: 2.4,
description: "Llama 3.1 Instruct (405B)",
@@ -358,7 +318,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.72,
outputPrice: 0.72,
description: "Llama 3.1 Instruct (70B)",
@@ -368,7 +327,6 @@ export const bedrockModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.9,
outputPrice: 0.9,
description: "Llama 3.1 Instruct (70B) (w/ latency optimized inference)",
@@ -378,7 +336,6 @@ export const bedrockModels = {
contextWindow: 8_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.22,
outputPrice: 0.22,
description: "Llama 3.1 Instruct (8B)",
@@ -388,7 +345,6 @@ export const bedrockModels = {
contextWindow: 8_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 2.65,
outputPrice: 3.5,
},
@@ -397,7 +353,6 @@ export const bedrockModels = {
contextWindow: 4_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.3,
outputPrice: 0.6,
},
@@ -406,7 +361,6 @@ export const bedrockModels = {
contextWindow: 8_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.15,
outputPrice: 0.2,
description: "Amazon Titan Text Lite",
@@ -416,7 +370,6 @@ export const bedrockModels = {
contextWindow: 8_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.2,
outputPrice: 0.6,
description: "Amazon Titan Text Express",
@@ -426,8 +379,6 @@ export const bedrockModels = {
contextWindow: 262_144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
preserveReasoning: true,
inputPrice: 0.6,
outputPrice: 2.5,
@@ -438,8 +389,6 @@ export const bedrockModels = {
contextWindow: 196_608,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
preserveReasoning: true,
inputPrice: 0.3,
outputPrice: 1.2,
@@ -450,8 +399,6 @@ export const bedrockModels = {
contextWindow: 262_144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.15,
outputPrice: 1.2,
description: "Qwen3 Next 80B (MoE model with 3B active parameters)",
@@ -461,8 +408,6 @@ export const bedrockModels = {
contextWindow: 262_144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.45,
outputPrice: 1.8,
description: "Qwen3 Coder 480B (MoE model with 35B active parameters)",
diff --git a/packages/types/src/providers/cerebras.ts b/packages/types/src/providers/cerebras.ts
index 37c063e83b..2e9fccaa9d 100644
--- a/packages/types/src/providers/cerebras.ts
+++ b/packages/types/src/providers/cerebras.ts
@@ -6,24 +6,13 @@ export type CerebrasModelId = keyof typeof cerebrasModels
export const cerebrasDefaultModelId: CerebrasModelId = "gpt-oss-120b"
export const cerebrasModels = {
- "zai-glm-4.6": {
- maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront)
- contextWindow: 131072,
- supportsImages: false,
- supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
- inputPrice: 0,
- outputPrice: 0,
- description: "Fast general-purpose model on Cerebras (up to 1,000 tokens/s). To be deprecated soon.",
- },
"zai-glm-4.7": {
maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront)
contextWindow: 131072,
supportsImages: false,
- supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
+ supportsPromptCache: true,
+ supportsTemperature: true,
+ defaultTemperature: 1.0,
inputPrice: 0,
outputPrice: 0,
description:
@@ -34,8 +23,6 @@ export const cerebrasModels = {
contextWindow: 64000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Intelligent model with ~1400 tokens/s",
@@ -45,8 +32,6 @@ export const cerebrasModels = {
contextWindow: 64000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Powerful model with ~2600 tokens/s",
@@ -56,8 +41,6 @@ export const cerebrasModels = {
contextWindow: 64000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "SOTA coding performance with ~2500 tokens/s",
@@ -67,8 +50,6 @@ export const cerebrasModels = {
contextWindow: 64000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description:
diff --git a/packages/types/src/providers/chutes.ts b/packages/types/src/providers/chutes.ts
index b21ffc392d..69e6b2e68b 100644
--- a/packages/types/src/providers/chutes.ts
+++ b/packages/types/src/providers/chutes.ts
@@ -51,8 +51,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek R1 0528 model.",
@@ -62,8 +60,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek R1 model.",
@@ -73,8 +69,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek V3 model.",
@@ -84,8 +78,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek V3.1 model.",
@@ -95,8 +87,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.23,
outputPrice: 0.9,
description:
@@ -107,8 +97,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 1.0,
outputPrice: 3.0,
description:
@@ -119,8 +107,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.25,
outputPrice: 0.35,
description:
@@ -131,8 +117,6 @@ export const chutesModels = {
contextWindow: 131072, // From Groq
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Unsloth Llama 3.3 70B Instruct model.",
@@ -142,8 +126,6 @@ export const chutesModels = {
contextWindow: 512000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "ChutesAI Llama 4 Scout 17B Instruct model, 512K context.",
@@ -153,8 +135,6 @@ export const chutesModels = {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Unsloth Mistral Nemo Instruct model.",
@@ -164,8 +144,6 @@ export const chutesModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Unsloth Gemma 3 12B IT model.",
@@ -175,8 +153,6 @@ export const chutesModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Nous DeepHermes 3 Llama 3 8B Preview model.",
@@ -186,8 +162,6 @@ export const chutesModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Unsloth Gemma 3 4B IT model.",
@@ -197,8 +171,6 @@ export const chutesModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Nvidia Llama 3.3 Nemotron Super 49B model.",
@@ -208,8 +180,6 @@ export const chutesModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Nvidia Llama 3.1 Nemotron Ultra 253B model.",
@@ -219,8 +189,6 @@ export const chutesModels = {
contextWindow: 256000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "ChutesAI Llama 4 Maverick 17B Instruct FP8 model.",
@@ -230,8 +198,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek V3 Base model.",
@@ -241,8 +207,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek R1 Zero model.",
@@ -252,8 +216,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek V3 (0324) model.",
@@ -263,8 +225,6 @@ export const chutesModels = {
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 235B A22B Instruct 2507 model with 262K context window.",
@@ -274,8 +234,6 @@ export const chutesModels = {
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 235B A22B model.",
@@ -285,8 +243,6 @@ export const chutesModels = {
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 32B model.",
@@ -296,8 +252,6 @@ export const chutesModels = {
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 30B A3B model.",
@@ -307,8 +261,6 @@ export const chutesModels = {
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 14B model.",
@@ -318,8 +270,6 @@ export const chutesModels = {
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 8B model.",
@@ -329,8 +279,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Microsoft MAI-DS-R1 FP8 model.",
@@ -340,8 +288,6 @@ export const chutesModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "TNGTech DeepSeek R1T Chimera model.",
@@ -351,8 +297,6 @@ export const chutesModels = {
contextWindow: 151329,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description:
@@ -363,8 +307,6 @@ export const chutesModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description:
@@ -375,8 +317,6 @@ export const chutesModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 1,
outputPrice: 3,
description: "GLM-4.5-turbo model with 128K token context window, optimized for fast inference.",
@@ -386,8 +326,6 @@ export const chutesModels = {
contextWindow: 202752,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description:
@@ -398,8 +336,6 @@ export const chutesModels = {
contextWindow: 202752,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 1.15,
outputPrice: 3.25,
description: "GLM-4.6-turbo model with 200K-token context window, optimized for fast inference.",
@@ -409,8 +345,6 @@ export const chutesModels = {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description:
@@ -421,8 +355,6 @@ export const chutesModels = {
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 Coder 480B A35B Instruct FP8 model, optimized for coding tasks.",
@@ -432,8 +364,6 @@ export const chutesModels = {
contextWindow: 75000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.1481,
outputPrice: 0.5926,
description: "Moonshot AI Kimi K2 Instruct model with 75k context window.",
@@ -443,8 +373,6 @@ export const chutesModels = {
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.1999,
outputPrice: 0.8001,
description: "Moonshot AI Kimi K2 Instruct 0905 model with 256k context window.",
@@ -454,8 +382,6 @@ export const chutesModels = {
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.077968332,
outputPrice: 0.31202496,
description: "Qwen3 235B A22B Thinking 2507 model with 262K context window.",
@@ -465,8 +391,6 @@ export const chutesModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description:
@@ -477,8 +401,6 @@ export const chutesModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
description:
@@ -489,8 +411,6 @@ export const chutesModels = {
contextWindow: 262144,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.16,
outputPrice: 0.65,
description:
diff --git a/packages/types/src/providers/claude-code.ts b/packages/types/src/providers/claude-code.ts
deleted file mode 100644
index 28863675d0..0000000000
--- a/packages/types/src/providers/claude-code.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import type { ModelInfo } from "../model.js"
-
-/**
- * Rate limit information from Claude Code API
- */
-export interface ClaudeCodeRateLimitInfo {
- // 5-hour limit info
- fiveHour: {
- status: string
- utilization: number
- resetTime: number // Unix timestamp
- }
- // 7-day (weekly) limit info (Sonnet-specific)
- weekly?: {
- status: string
- utilization: number
- resetTime: number // Unix timestamp
- }
- // 7-day unified limit info
- weeklyUnified?: {
- status: string
- utilization: number
- resetTime: number // Unix timestamp
- }
- // Representative claim type
- representativeClaim?: string
- // Overage status
- overage?: {
- status: string
- disabledReason?: string
- }
- // Fallback percentage
- fallbackPercentage?: number
- // Organization ID
- organizationId?: string
- // Timestamp when this was fetched
- fetchedAt: number
-}
-
-// Regex pattern to strip date suffix from model names
-const DATE_SUFFIX_PATTERN = /-\d{8}$/
-
-// Models that work with Claude Code OAuth tokens
-// See: https://docs.anthropic.com/en/docs/claude-code
-// NOTE: Claude Code is subscription-based with no per-token cost - pricing fields are 0
-export const claudeCodeModels = {
- "claude-haiku-4-5": {
- maxTokens: 32768,
- contextWindow: 200_000,
- supportsImages: true,
- supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
- supportsReasoningEffort: ["disable", "low", "medium", "high"],
- reasoningEffort: "medium",
- description: "Claude Haiku 4.5 - Fast and efficient with thinking",
- },
- "claude-sonnet-4-5": {
- maxTokens: 32768,
- contextWindow: 200_000,
- supportsImages: true,
- supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
- supportsReasoningEffort: ["disable", "low", "medium", "high"],
- reasoningEffort: "medium",
- description: "Claude Sonnet 4.5 - Balanced performance with thinking",
- },
- "claude-opus-4-5": {
- maxTokens: 32768,
- contextWindow: 200_000,
- supportsImages: true,
- supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
- supportsReasoningEffort: ["disable", "low", "medium", "high"],
- reasoningEffort: "medium",
- description: "Claude Opus 4.5 - Most capable with thinking",
- },
-} as const satisfies Record
-
-// Claude Code - Only models that work with Claude Code OAuth tokens
-export type ClaudeCodeModelId = keyof typeof claudeCodeModels
-export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-5"
-
-/**
- * Model family patterns for normalization.
- * Maps regex patterns to their canonical Claude Code model IDs.
- *
- * Order matters - more specific patterns should come first.
- */
-const MODEL_FAMILY_PATTERNS: Array<{ pattern: RegExp; target: ClaudeCodeModelId }> = [
- // Opus models (any version) → claude-opus-4-5
- { pattern: /opus/i, target: "claude-opus-4-5" },
- // Haiku models (any version) → claude-haiku-4-5
- { pattern: /haiku/i, target: "claude-haiku-4-5" },
- // Sonnet models (any version) → claude-sonnet-4-5
- { pattern: /sonnet/i, target: "claude-sonnet-4-5" },
-]
-
-/**
- * Normalizes a Claude model ID to a valid Claude Code model ID.
- *
- * This function handles backward compatibility for legacy model names
- * that may include version numbers or date suffixes. It maps:
- * - claude-sonnet-4-5-20250929, claude-sonnet-4-20250514, claude-3-7-sonnet-20250219, claude-3-5-sonnet-20241022 → claude-sonnet-4-5
- * - claude-opus-4-5-20251101, claude-opus-4-1-20250805, claude-opus-4-20250514 → claude-opus-4-5
- * - claude-haiku-4-5-20251001, claude-3-5-haiku-20241022 → claude-haiku-4-5
- *
- * @param modelId - The model ID to normalize (may be a legacy format)
- * @returns A valid ClaudeCodeModelId, or the original ID if already valid
- *
- * @example
- * normalizeClaudeCodeModelId("claude-sonnet-4-5") // returns "claude-sonnet-4-5"
- * normalizeClaudeCodeModelId("claude-3-5-sonnet-20241022") // returns "claude-sonnet-4-5"
- * normalizeClaudeCodeModelId("claude-opus-4-1-20250805") // returns "claude-opus-4-5"
- */
-export function normalizeClaudeCodeModelId(modelId: string): ClaudeCodeModelId {
- // If already a valid model ID, return as-is
- // Use Object.hasOwn() instead of 'in' operator to avoid matching inherited properties like 'toString'
- if (Object.hasOwn(claudeCodeModels, modelId)) {
- return modelId as ClaudeCodeModelId
- }
-
- // Strip date suffix if present (e.g., -20250514)
- const withoutDate = modelId.replace(DATE_SUFFIX_PATTERN, "")
-
- // Check if stripping the date makes it valid
- if (Object.hasOwn(claudeCodeModels, withoutDate)) {
- return withoutDate as ClaudeCodeModelId
- }
-
- // Match by model family
- for (const { pattern, target } of MODEL_FAMILY_PATTERNS) {
- if (pattern.test(modelId)) {
- return target
- }
- }
-
- // Fallback to default if no match (shouldn't happen with valid Claude models)
- return claudeCodeDefaultModelId
-}
-
-/**
- * Reasoning effort configuration for Claude Code thinking mode.
- * Maps reasoning effort level to budget_tokens for the thinking process.
- *
- * Note: With interleaved thinking (enabled via beta header), budget_tokens
- * can exceed max_tokens as the token limit becomes the entire context window.
- * The max_tokens is drawn from the model's maxTokens definition.
- *
- * @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking
- */
-export const claudeCodeReasoningConfig = {
- low: { budgetTokens: 16_000 },
- medium: { budgetTokens: 32_000 },
- high: { budgetTokens: 64_000 },
-} as const
-
-export type ClaudeCodeReasoningLevel = keyof typeof claudeCodeReasoningConfig
diff --git a/packages/types/src/providers/deepinfra.ts b/packages/types/src/providers/deepinfra.ts
index 9c487e71b2..9a430b3789 100644
--- a/packages/types/src/providers/deepinfra.ts
+++ b/packages/types/src/providers/deepinfra.ts
@@ -8,7 +8,6 @@ export const deepInfraDefaultModelInfo: ModelInfo = {
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.3,
outputPrice: 1.2,
description: "Qwen 3 Coder 480B A35B Instruct Turbo model, 256K context.",
diff --git a/packages/types/src/providers/deepseek.ts b/packages/types/src/providers/deepseek.ts
index 80c72ba725..40722471cb 100644
--- a/packages/types/src/providers/deepseek.ts
+++ b/packages/types/src/providers/deepseek.ts
@@ -14,8 +14,6 @@ export const deepSeekModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025
outputPrice: 0.42, // $0.42 per million tokens - Updated Dec 9, 2025
cacheWritesPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025
@@ -27,8 +25,6 @@ export const deepSeekModels = {
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
preserveReasoning: true,
inputPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025
outputPrice: 0.42, // $0.42 per million tokens - Updated Dec 9, 2025
diff --git a/packages/types/src/providers/doubao.ts b/packages/types/src/providers/doubao.ts
index c0187f7a75..f948450bc4 100644
--- a/packages/types/src/providers/doubao.ts
+++ b/packages/types/src/providers/doubao.ts
@@ -8,8 +8,6 @@ export const doubaoModels = {
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.0001, // $0.0001 per million tokens (cache miss)
outputPrice: 0.0004, // $0.0004 per million tokens
cacheWritesPrice: 0.0001, // $0.0001 per million tokens (cache miss)
@@ -21,8 +19,6 @@ export const doubaoModels = {
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.0002, // $0.0002 per million tokens
outputPrice: 0.0008, // $0.0008 per million tokens
cacheWritesPrice: 0.0002, // $0.0002 per million
@@ -34,8 +30,6 @@ export const doubaoModels = {
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.00015, // $0.00015 per million tokens
outputPrice: 0.0006, // $0.0006 per million tokens
cacheWritesPrice: 0.00015, // $0.00015 per million
diff --git a/packages/types/src/providers/featherless.ts b/packages/types/src/providers/featherless.ts
index 63bcb98968..20cfe96654 100644
--- a/packages/types/src/providers/featherless.ts
+++ b/packages/types/src/providers/featherless.ts
@@ -13,7 +13,6 @@ export const featherlessModels = {
contextWindow: 32678,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek V3 0324 model.",
@@ -23,7 +22,6 @@ export const featherlessModels = {
contextWindow: 32678,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek R1 0528 model.",
@@ -33,7 +31,6 @@ export const featherlessModels = {
contextWindow: 32678,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0,
outputPrice: 0,
description: "Kimi K2 Instruct model.",
@@ -43,7 +40,6 @@ export const featherlessModels = {
contextWindow: 32678,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0,
outputPrice: 0,
description: "GPT-OSS 120B model.",
@@ -53,7 +49,6 @@ export const featherlessModels = {
contextWindow: 32678,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0,
outputPrice: 0,
description: "Qwen3 Coder 480B A35B Instruct model.",
diff --git a/packages/types/src/providers/fireworks.ts b/packages/types/src/providers/fireworks.ts
index 3f7b17034e..1642424045 100644
--- a/packages/types/src/providers/fireworks.ts
+++ b/packages/types/src/providers/fireworks.ts
@@ -5,16 +5,22 @@ export type FireworksModelId =
| "accounts/fireworks/models/kimi-k2-instruct-0905"
| "accounts/fireworks/models/kimi-k2-thinking"
| "accounts/fireworks/models/minimax-m2"
+ | "accounts/fireworks/models/minimax-m2p1"
| "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507"
| "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct"
| "accounts/fireworks/models/deepseek-r1-0528"
| "accounts/fireworks/models/deepseek-v3"
| "accounts/fireworks/models/deepseek-v3p1"
+ | "accounts/fireworks/models/deepseek-v3p2"
| "accounts/fireworks/models/glm-4p5"
| "accounts/fireworks/models/glm-4p5-air"
| "accounts/fireworks/models/glm-4p6"
+ | "accounts/fireworks/models/glm-4p7"
| "accounts/fireworks/models/gpt-oss-20b"
| "accounts/fireworks/models/gpt-oss-120b"
+ | "accounts/fireworks/models/llama-v3p3-70b-instruct"
+ | "accounts/fireworks/models/llama4-maverick-instruct-basic"
+ | "accounts/fireworks/models/llama4-scout-instruct-basic"
export const fireworksDefaultModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct-0905"
@@ -24,8 +30,6 @@ export const fireworksModels = {
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.6,
outputPrice: 2.5,
cacheReadsPrice: 0.15,
@@ -37,8 +41,6 @@ export const fireworksModels = {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.6,
outputPrice: 2.5,
description:
@@ -49,7 +51,6 @@ export const fireworksModels = {
contextWindow: 256000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
supportsTemperature: true,
preserveReasoning: true,
defaultTemperature: 1.0,
@@ -64,8 +65,6 @@ export const fireworksModels = {
contextWindow: 204800,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.3,
outputPrice: 1.2,
description:
@@ -76,8 +75,6 @@ export const fireworksModels = {
contextWindow: 256000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.22,
outputPrice: 0.88,
description: "Latest Qwen3 thinking model, competitive against the best closed source models in Jul 2025.",
@@ -87,8 +84,6 @@ export const fireworksModels = {
contextWindow: 256000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.45,
outputPrice: 1.8,
description: "Qwen3's most agentic code model to date.",
@@ -98,8 +93,6 @@ export const fireworksModels = {
contextWindow: 160000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3,
outputPrice: 8,
description:
@@ -110,8 +103,6 @@ export const fireworksModels = {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.9,
outputPrice: 0.9,
description:
@@ -122,8 +113,6 @@ export const fireworksModels = {
contextWindow: 163840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.56,
outputPrice: 1.68,
description:
@@ -134,8 +123,6 @@ export const fireworksModels = {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.55,
outputPrice: 2.19,
description:
@@ -146,8 +133,6 @@ export const fireworksModels = {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.55,
outputPrice: 2.19,
description:
@@ -158,8 +143,6 @@ export const fireworksModels = {
contextWindow: 198000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.55,
outputPrice: 2.19,
description:
@@ -170,8 +153,6 @@ export const fireworksModels = {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.07,
outputPrice: 0.3,
description:
@@ -182,11 +163,69 @@ export const fireworksModels = {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.15,
outputPrice: 0.6,
description:
"OpenAI gpt-oss-120b: Production-grade, general-purpose model that fits on a single H100 GPU. Features complex reasoning, configurable effort, full chain-of-thought transparency, and supports function calling, tool use, and structured outputs.",
},
+ "accounts/fireworks/models/minimax-m2p1": {
+ maxTokens: 4096,
+ contextWindow: 204800,
+ supportsImages: false,
+ supportsPromptCache: false,
+ inputPrice: 0.3,
+ outputPrice: 1.2,
+ description:
+ "MiniMax M2.1 is an upgraded version of M2 with improved performance on complex reasoning, coding, and long-context understanding tasks.",
+ },
+ "accounts/fireworks/models/deepseek-v3p2": {
+ maxTokens: 16384,
+ contextWindow: 163840,
+ supportsImages: false,
+ supportsPromptCache: false,
+ inputPrice: 0.56,
+ outputPrice: 1.68,
+ description:
+ "DeepSeek V3.2 is the latest iteration of the V3 model family with enhanced reasoning capabilities, improved code generation, and better instruction following.",
+ },
+ "accounts/fireworks/models/glm-4p7": {
+ maxTokens: 25344,
+ contextWindow: 198000,
+ supportsImages: false,
+ supportsPromptCache: false,
+ inputPrice: 0.55,
+ outputPrice: 2.19,
+ description:
+ "Z.ai GLM-4.7 is the latest coding model with exceptional performance on complex programming tasks. Features improved reasoning capabilities and enhanced code generation quality.",
+ },
+ "accounts/fireworks/models/llama-v3p3-70b-instruct": {
+ maxTokens: 16384,
+ contextWindow: 131072,
+ supportsImages: false,
+ supportsPromptCache: false,
+ inputPrice: 0.9,
+ outputPrice: 0.9,
+ description:
+ "Meta Llama 3.3 70B Instruct is a highly capable instruction-tuned model with strong reasoning, coding, and general task performance.",
+ },
+ "accounts/fireworks/models/llama4-maverick-instruct-basic": {
+ maxTokens: 16384,
+ contextWindow: 131072,
+ supportsImages: true,
+ supportsPromptCache: false,
+ inputPrice: 0.22,
+ outputPrice: 0.88,
+ description:
+ "Llama 4 Maverick is Meta's latest multimodal model with vision capabilities, optimized for instruction following and coding tasks.",
+ },
+ "accounts/fireworks/models/llama4-scout-instruct-basic": {
+ maxTokens: 16384,
+ contextWindow: 131072,
+ supportsImages: true,
+ supportsPromptCache: false,
+ inputPrice: 0.15,
+ outputPrice: 0.6,
+ description:
+ "Llama 4 Scout is a smaller, faster variant of Llama 4 with multimodal capabilities, ideal for quick iterations and cost-effective deployments.",
+ },
} as const satisfies Record
diff --git a/packages/types/src/providers/gemini.ts b/packages/types/src/providers/gemini.ts
index 6d35e093e8..18aa2b7751 100644
--- a/packages/types/src/providers/gemini.ts
+++ b/packages/types/src/providers/gemini.ts
@@ -10,8 +10,6 @@ export const geminiModels = {
maxTokens: 65_536,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
supportsReasoningEffort: ["low", "high"],
reasoningEffort: "low",
@@ -20,16 +18,19 @@ export const geminiModels = {
defaultTemperature: 1,
inputPrice: 4.0,
outputPrice: 18.0,
+ cacheReadsPrice: 0.4,
tiers: [
{
contextWindow: 200_000,
inputPrice: 2.0,
outputPrice: 12.0,
+ cacheReadsPrice: 0.2,
},
{
contextWindow: Infinity,
inputPrice: 4.0,
outputPrice: 18.0,
+ cacheReadsPrice: 0.4,
},
],
},
@@ -37,26 +38,21 @@ export const geminiModels = {
maxTokens: 65_536,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
reasoningEffort: "medium",
supportsTemperature: true,
defaultTemperature: 1,
- inputPrice: 0.3,
- outputPrice: 2.5,
- cacheReadsPrice: 0.075,
- cacheWritesPrice: 1.0,
+ inputPrice: 0.5,
+ outputPrice: 3.0,
+ cacheReadsPrice: 0.05,
},
// 2.5 Pro models
"gemini-2.5-pro": {
maxTokens: 64_000,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 2.5, // This is the pricing for prompts above 200k tokens.
@@ -85,8 +81,6 @@ export const geminiModels = {
maxTokens: 65_535,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 2.5, // This is the pricing for prompts above 200k tokens.
@@ -114,8 +108,6 @@ export const geminiModels = {
maxTokens: 65_535,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 2.5, // This is the pricing for prompts above 200k tokens.
@@ -141,8 +133,6 @@ export const geminiModels = {
maxTokens: 65_535,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 2.5, // This is the pricing for prompts above 200k tokens.
@@ -172,8 +162,6 @@ export const geminiModels = {
maxTokens: 65_536,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 0.3,
@@ -187,8 +175,6 @@ export const geminiModels = {
maxTokens: 65_536,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 0.3,
@@ -202,8 +188,6 @@ export const geminiModels = {
maxTokens: 64_000,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 0.3,
@@ -219,8 +203,6 @@ export const geminiModels = {
maxTokens: 65_536,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 0.1,
@@ -234,8 +216,6 @@ export const geminiModels = {
maxTokens: 65_536,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 0.1,
diff --git a/packages/types/src/providers/groq.ts b/packages/types/src/providers/groq.ts
index a22ad764ee..30e7c42ca1 100644
--- a/packages/types/src/providers/groq.ts
+++ b/packages/types/src/providers/groq.ts
@@ -19,8 +19,6 @@ export const groqModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.05,
outputPrice: 0.08,
description: "Meta Llama 3.1 8B Instant model, 128K context.",
@@ -30,8 +28,6 @@ export const groqModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.59,
outputPrice: 0.79,
description: "Meta Llama 3.3 70B Versatile model, 128K context.",
@@ -41,8 +37,6 @@ export const groqModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.11,
outputPrice: 0.34,
description: "Meta Llama 4 Scout 17B Instruct model, 128K context.",
@@ -52,8 +46,6 @@ export const groqModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.29,
outputPrice: 0.59,
description: "Alibaba Qwen 3 32B model, 128K context.",
@@ -63,8 +55,6 @@ export const groqModels = {
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.6,
outputPrice: 2.5,
cacheReadsPrice: 0.15,
@@ -76,8 +66,6 @@ export const groqModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.15,
outputPrice: 0.75,
description:
@@ -88,8 +76,6 @@ export const groqModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.1,
outputPrice: 0.5,
description:
diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts
index 3c6741fcd8..2018954bbd 100644
--- a/packages/types/src/providers/index.ts
+++ b/packages/types/src/providers/index.ts
@@ -3,7 +3,6 @@ export * from "./baseten.js"
export * from "./bedrock.js"
export * from "./cerebras.js"
export * from "./chutes.js"
-export * from "./claude-code.js"
export * from "./deepseek.js"
export * from "./doubao.js"
export * from "./featherless.js"
@@ -19,6 +18,7 @@ export * from "./moonshot.js"
export * from "./ollama.js"
export * from "./openai.js"
export * from "./openai-codex.js"
+export * from "./openai-codex-rate-limits.js"
export * from "./openrouter.js"
export * from "./qwen-code.js"
export * from "./requesty.js"
@@ -38,7 +38,6 @@ import { basetenDefaultModelId } from "./baseten.js"
import { bedrockDefaultModelId } from "./bedrock.js"
import { cerebrasDefaultModelId } from "./cerebras.js"
import { chutesDefaultModelId } from "./chutes.js"
-import { claudeCodeDefaultModelId } from "./claude-code.js"
import { deepSeekDefaultModelId } from "./deepseek.js"
import { doubaoDefaultModelId } from "./doubao.js"
import { featherlessDefaultModelId } from "./featherless.js"
@@ -127,8 +126,6 @@ export function getProviderDefaultModelId(
return deepInfraDefaultModelId
case "vscode-lm":
return vscodeLlmDefaultModelId
- case "claude-code":
- return claudeCodeDefaultModelId
case "cerebras":
return cerebrasDefaultModelId
case "sambanova":
diff --git a/packages/types/src/providers/io-intelligence.ts b/packages/types/src/providers/io-intelligence.ts
index 573db6b97a..a9b845393f 100644
--- a/packages/types/src/providers/io-intelligence.ts
+++ b/packages/types/src/providers/io-intelligence.ts
@@ -18,7 +18,6 @@ export const ioIntelligenceModels = {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
description: "DeepSeek R1 reasoning model",
},
"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": {
@@ -26,7 +25,6 @@ export const ioIntelligenceModels = {
contextWindow: 430000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
description: "Llama 4 Maverick 17B model",
},
"Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar": {
@@ -34,7 +32,6 @@ export const ioIntelligenceModels = {
contextWindow: 106000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
description: "Qwen3 Coder 480B specialized for coding",
},
"openai/gpt-oss-120b": {
@@ -42,7 +39,6 @@ export const ioIntelligenceModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
description: "OpenAI GPT-OSS 120B model",
},
} as const satisfies Record
diff --git a/packages/types/src/providers/lite-llm.ts b/packages/types/src/providers/lite-llm.ts
index 9ee0351458..14a68cfc3c 100644
--- a/packages/types/src/providers/lite-llm.ts
+++ b/packages/types/src/providers/lite-llm.ts
@@ -8,8 +8,6 @@ export const litellmDefaultModelInfo: ModelInfo = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
diff --git a/packages/types/src/providers/lm-studio.ts b/packages/types/src/providers/lm-studio.ts
index a5a1202c2e..d0df134470 100644
--- a/packages/types/src/providers/lm-studio.ts
+++ b/packages/types/src/providers/lm-studio.ts
@@ -10,8 +10,6 @@ export const lMStudioDefaultModelInfo: ModelInfo = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
cacheWritesPrice: 0,
diff --git a/packages/types/src/providers/minimax.ts b/packages/types/src/providers/minimax.ts
index 7152946f7f..96dd71769d 100644
--- a/packages/types/src/providers/minimax.ts
+++ b/packages/types/src/providers/minimax.ts
@@ -13,8 +13,6 @@ export const minimaxModels = {
contextWindow: 192_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["search_and_replace"],
excludedTools: ["apply_diff"],
preserveReasoning: true,
@@ -30,8 +28,6 @@ export const minimaxModels = {
contextWindow: 192_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["search_and_replace"],
excludedTools: ["apply_diff"],
preserveReasoning: true,
@@ -47,8 +43,6 @@ export const minimaxModels = {
contextWindow: 192_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["search_and_replace"],
excludedTools: ["apply_diff"],
preserveReasoning: true,
diff --git a/packages/types/src/providers/mistral.ts b/packages/types/src/providers/mistral.ts
index 4f12d288ee..0b030c80d4 100644
--- a/packages/types/src/providers/mistral.ts
+++ b/packages/types/src/providers/mistral.ts
@@ -11,8 +11,6 @@ export const mistralModels = {
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 2.0,
outputPrice: 5.0,
},
@@ -21,8 +19,6 @@ export const mistralModels = {
contextWindow: 131_000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.4,
outputPrice: 2.0,
},
@@ -31,8 +27,6 @@ export const mistralModels = {
contextWindow: 131_000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.4,
outputPrice: 2.0,
},
@@ -41,8 +35,6 @@ export const mistralModels = {
contextWindow: 256_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.3,
outputPrice: 0.9,
},
@@ -51,8 +43,6 @@ export const mistralModels = {
contextWindow: 131_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 2.0,
outputPrice: 6.0,
},
@@ -61,8 +51,6 @@ export const mistralModels = {
contextWindow: 131_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.1,
outputPrice: 0.1,
},
@@ -71,8 +59,6 @@ export const mistralModels = {
contextWindow: 131_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.04,
outputPrice: 0.04,
},
@@ -81,8 +67,6 @@ export const mistralModels = {
contextWindow: 32_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.2,
outputPrice: 0.6,
},
@@ -91,8 +75,6 @@ export const mistralModels = {
contextWindow: 131_000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 2.0,
outputPrice: 6.0,
},
diff --git a/packages/types/src/providers/moonshot.ts b/packages/types/src/providers/moonshot.ts
index 7279c71809..a825475644 100644
--- a/packages/types/src/providers/moonshot.ts
+++ b/packages/types/src/providers/moonshot.ts
@@ -11,8 +11,6 @@ export const moonshotModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.6, // $0.60 per million tokens (cache miss)
outputPrice: 2.5, // $2.50 per million tokens
cacheWritesPrice: 0, // $0 per million tokens (cache miss)
@@ -24,8 +22,6 @@ export const moonshotModels = {
contextWindow: 262144,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.6,
outputPrice: 2.5,
cacheReadsPrice: 0.15,
@@ -37,8 +33,6 @@ export const moonshotModels = {
contextWindow: 262_144,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 2.4, // $2.40 per million tokens (cache miss)
outputPrice: 10, // $10.00 per million tokens
cacheWritesPrice: 0, // $0 per million tokens (cache miss)
@@ -50,8 +44,6 @@ export const moonshotModels = {
contextWindow: 262_144, // 262,144 tokens
supportsImages: false, // Text-only (no image/vision support)
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.6, // $0.60 per million tokens (cache miss)
outputPrice: 2.5, // $2.50 per million tokens
cacheWritesPrice: 0, // $0 per million tokens (cache miss)
@@ -61,6 +53,19 @@ export const moonshotModels = {
defaultTemperature: 1.0,
description: `The kimi-k2-thinking model is a general-purpose agentic reasoning model developed by Moonshot AI. Thanks to its strength in deep reasoning and multi-turn tool use, it can solve even the hardest problems.`,
},
+ "kimi-k2.5": {
+ maxTokens: 16_384,
+ contextWindow: 262_144,
+ supportsImages: false,
+ supportsPromptCache: true,
+ inputPrice: 0.6, // $0.60 per million tokens (cache miss)
+ outputPrice: 3.0, // $3.00 per million tokens
+ cacheReadsPrice: 0.1, // $0.10 per million tokens (cache hit)
+ supportsTemperature: true,
+ defaultTemperature: 1.0,
+ description:
+ "Kimi K2.5 is the latest generation of Moonshot AI's Kimi series, featuring improved reasoning capabilities and enhanced performance across diverse tasks.",
+ },
} as const satisfies Record
export const MOONSHOT_DEFAULT_TEMPERATURE = 0.6
diff --git a/packages/types/src/providers/ollama.ts b/packages/types/src/providers/ollama.ts
index 5148f466c0..160083511f 100644
--- a/packages/types/src/providers/ollama.ts
+++ b/packages/types/src/providers/ollama.ts
@@ -8,7 +8,6 @@ export const ollamaDefaultModelInfo: ModelInfo = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 0,
outputPrice: 0,
cacheWritesPrice: 0,
diff --git a/packages/types/src/providers/openai-codex-rate-limits.ts b/packages/types/src/providers/openai-codex-rate-limits.ts
new file mode 100644
index 0000000000..98ddae2a17
--- /dev/null
+++ b/packages/types/src/providers/openai-codex-rate-limits.ts
@@ -0,0 +1,29 @@
+/**
+ * OpenAI Codex usage/rate limit information (ChatGPT subscription)
+ */
+export interface OpenAiCodexRateLimitInfo {
+ primary?: {
+ /** Used percent in 0–100 */
+ usedPercent: number
+ /** Window length in minutes, when provided */
+ windowMinutes?: number
+ /** Reset time (unix ms since epoch), when provided */
+ resetsAt?: number
+ }
+ secondary?: {
+ /** Used percent in 0–100 */
+ usedPercent: number
+ /** Window length in minutes, when provided */
+ windowMinutes?: number
+ /** Reset time (unix ms since epoch), when provided */
+ resetsAt?: number
+ }
+ credits?: {
+ hasCredits: boolean
+ unlimited: boolean
+ balance?: string
+ }
+ planType?: string
+ /** Timestamp when this was fetched (unix ms since epoch) */
+ fetchedAt: number
+}
diff --git a/packages/types/src/providers/openai-codex.ts b/packages/types/src/providers/openai-codex.ts
index e9cf5e170c..7722c84814 100644
--- a/packages/types/src/providers/openai-codex.ts
+++ b/packages/types/src/providers/openai-codex.ts
@@ -27,8 +27,6 @@ export const openAiCodexModels = {
"gpt-5.1-codex-max": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -41,11 +39,24 @@ export const openAiCodexModels = {
supportsTemperature: false,
description: "GPT-5.1 Codex Max: Maximum capability coding model via ChatGPT subscription",
},
+ "gpt-5.1-codex": {
+ maxTokens: 128000,
+ contextWindow: 400000,
+ includedTools: ["apply_patch"],
+ excludedTools: ["apply_diff", "write_to_file"],
+ supportsImages: true,
+ supportsPromptCache: true,
+ supportsReasoningEffort: ["low", "medium", "high"],
+ reasoningEffort: "medium",
+ // Subscription-based: no per-token costs
+ inputPrice: 0,
+ outputPrice: 0,
+ supportsTemperature: false,
+ description: "GPT-5.1 Codex: GPT-5.1 optimized for agentic coding via ChatGPT subscription",
+ },
"gpt-5.2-codex": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -57,11 +68,71 @@ export const openAiCodexModels = {
supportsTemperature: false,
description: "GPT-5.2 Codex: OpenAI's flagship coding model via ChatGPT subscription",
},
+ "gpt-5.1": {
+ maxTokens: 128000,
+ contextWindow: 400000,
+ includedTools: ["apply_patch"],
+ excludedTools: ["apply_diff", "write_to_file"],
+ supportsImages: true,
+ supportsPromptCache: true,
+ supportsReasoningEffort: ["none", "low", "medium", "high"],
+ reasoningEffort: "medium",
+ // Subscription-based: no per-token costs
+ inputPrice: 0,
+ outputPrice: 0,
+ supportsVerbosity: true,
+ supportsTemperature: false,
+ description: "GPT-5.1: General GPT-5.1 model via ChatGPT subscription",
+ },
+ "gpt-5": {
+ maxTokens: 128000,
+ contextWindow: 400000,
+ includedTools: ["apply_patch"],
+ excludedTools: ["apply_diff", "write_to_file"],
+ supportsImages: true,
+ supportsPromptCache: true,
+ supportsReasoningEffort: ["minimal", "low", "medium", "high"],
+ reasoningEffort: "medium",
+ // Subscription-based: no per-token costs
+ inputPrice: 0,
+ outputPrice: 0,
+ supportsVerbosity: true,
+ supportsTemperature: false,
+ description: "GPT-5: General GPT-5 model via ChatGPT subscription",
+ },
+ "gpt-5-codex": {
+ maxTokens: 128000,
+ contextWindow: 400000,
+ includedTools: ["apply_patch"],
+ excludedTools: ["apply_diff", "write_to_file"],
+ supportsImages: true,
+ supportsPromptCache: true,
+ supportsReasoningEffort: ["low", "medium", "high"],
+ reasoningEffort: "medium",
+ // Subscription-based: no per-token costs
+ inputPrice: 0,
+ outputPrice: 0,
+ supportsTemperature: false,
+ description: "GPT-5 Codex: GPT-5 optimized for agentic coding via ChatGPT subscription",
+ },
+ "gpt-5-codex-mini": {
+ maxTokens: 128000,
+ contextWindow: 400000,
+ includedTools: ["apply_patch"],
+ excludedTools: ["apply_diff", "write_to_file"],
+ supportsImages: true,
+ supportsPromptCache: true,
+ supportsReasoningEffort: ["low", "medium", "high"],
+ reasoningEffort: "medium",
+ // Subscription-based: no per-token costs
+ inputPrice: 0,
+ outputPrice: 0,
+ supportsTemperature: false,
+ description: "GPT-5 Codex Mini: Faster coding model via ChatGPT subscription",
+ },
"gpt-5.1-codex-mini": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -76,8 +147,6 @@ export const openAiCodexModels = {
"gpt-5.2": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts
index 57b0dae564..af9a1ff759 100644
--- a/packages/types/src/providers/openai.ts
+++ b/packages/types/src/providers/openai.ts
@@ -9,8 +9,6 @@ export const openAiNativeModels = {
"gpt-5.1-codex-max": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -29,8 +27,6 @@ export const openAiNativeModels = {
"gpt-5.2": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -52,8 +48,6 @@ export const openAiNativeModels = {
"gpt-5.2-codex": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -72,8 +66,6 @@ export const openAiNativeModels = {
"gpt-5.2-chat-latest": {
maxTokens: 16_384,
contextWindow: 128_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -86,8 +78,6 @@ export const openAiNativeModels = {
"gpt-5.1": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -109,8 +99,6 @@ export const openAiNativeModels = {
"gpt-5.1-codex": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -128,8 +116,6 @@ export const openAiNativeModels = {
"gpt-5.1-codex-mini": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -146,8 +132,6 @@ export const openAiNativeModels = {
"gpt-5": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -168,8 +152,6 @@ export const openAiNativeModels = {
"gpt-5-mini": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -190,8 +172,6 @@ export const openAiNativeModels = {
"gpt-5-codex": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -208,8 +188,6 @@ export const openAiNativeModels = {
"gpt-5-nano": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -227,8 +205,6 @@ export const openAiNativeModels = {
"gpt-5-chat-latest": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -241,8 +217,6 @@ export const openAiNativeModels = {
"gpt-4.1": {
maxTokens: 32_768,
contextWindow: 1_047_576,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -258,8 +232,6 @@ export const openAiNativeModels = {
"gpt-4.1-mini": {
maxTokens: 32_768,
contextWindow: 1_047_576,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -275,8 +247,6 @@ export const openAiNativeModels = {
"gpt-4.1-nano": {
maxTokens: 32_768,
contextWindow: 1_047_576,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -292,8 +262,6 @@ export const openAiNativeModels = {
o3: {
maxTokens: 100_000,
contextWindow: 200_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.0,
@@ -310,8 +278,6 @@ export const openAiNativeModels = {
"o3-high": {
maxTokens: 100_000,
contextWindow: 200_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.0,
@@ -323,8 +289,6 @@ export const openAiNativeModels = {
"o3-low": {
maxTokens: 100_000,
contextWindow: 200_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.0,
@@ -336,8 +300,6 @@ export const openAiNativeModels = {
"o4-mini": {
maxTokens: 100_000,
contextWindow: 200_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.1,
@@ -354,8 +316,6 @@ export const openAiNativeModels = {
"o4-mini-high": {
maxTokens: 100_000,
contextWindow: 200_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.1,
@@ -367,8 +327,6 @@ export const openAiNativeModels = {
"o4-mini-low": {
maxTokens: 100_000,
contextWindow: 200_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.1,
@@ -380,8 +338,6 @@ export const openAiNativeModels = {
"o3-mini": {
maxTokens: 100_000,
contextWindow: 200_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: false,
supportsPromptCache: true,
inputPrice: 1.1,
@@ -394,8 +350,6 @@ export const openAiNativeModels = {
"o3-mini-high": {
maxTokens: 100_000,
contextWindow: 200_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: false,
supportsPromptCache: true,
inputPrice: 1.1,
@@ -407,8 +361,6 @@ export const openAiNativeModels = {
"o3-mini-low": {
maxTokens: 100_000,
contextWindow: 200_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: false,
supportsPromptCache: true,
inputPrice: 1.1,
@@ -420,8 +372,6 @@ export const openAiNativeModels = {
o1: {
maxTokens: 100_000,
contextWindow: 200_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: true,
supportsPromptCache: true,
inputPrice: 15,
@@ -432,8 +382,6 @@ export const openAiNativeModels = {
"o1-preview": {
maxTokens: 32_768,
contextWindow: 128_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: true,
supportsPromptCache: true,
inputPrice: 15,
@@ -444,8 +392,6 @@ export const openAiNativeModels = {
"o1-mini": {
maxTokens: 65_536,
contextWindow: 128_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.1,
@@ -456,8 +402,6 @@ export const openAiNativeModels = {
"gpt-4o": {
maxTokens: 16_384,
contextWindow: 128_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.5,
@@ -471,8 +415,6 @@ export const openAiNativeModels = {
"gpt-4o-mini": {
maxTokens: 16_384,
contextWindow: 128_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.15,
@@ -486,8 +428,6 @@ export const openAiNativeModels = {
"codex-mini-latest": {
maxTokens: 16_384,
contextWindow: 200_000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsImages: false,
supportsPromptCache: false,
inputPrice: 1.5,
@@ -501,8 +441,6 @@ export const openAiNativeModels = {
"gpt-5-2025-08-07": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -523,8 +461,6 @@ export const openAiNativeModels = {
"gpt-5-mini-2025-08-07": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -545,8 +481,6 @@ export const openAiNativeModels = {
"gpt-5-nano-2025-08-07": {
maxTokens: 128000,
contextWindow: 400000,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
supportsImages: true,
@@ -570,8 +504,6 @@ export const openAiModelInfoSaneDefaults: ModelInfo = {
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
}
// https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation
diff --git a/packages/types/src/providers/openrouter.ts b/packages/types/src/providers/openrouter.ts
index 5cf82d3501..f3fb13baa9 100644
--- a/packages/types/src/providers/openrouter.ts
+++ b/packages/types/src/providers/openrouter.ts
@@ -8,7 +8,6 @@ export const openRouterDefaultModelInfo: ModelInfo = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
diff --git a/packages/types/src/providers/qwen-code.ts b/packages/types/src/providers/qwen-code.ts
index e1102011aa..0f51e4eacb 100644
--- a/packages/types/src/providers/qwen-code.ts
+++ b/packages/types/src/providers/qwen-code.ts
@@ -10,8 +10,6 @@ export const qwenCodeModels = {
contextWindow: 1_000_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
cacheWritesPrice: 0,
@@ -23,8 +21,6 @@ export const qwenCodeModels = {
contextWindow: 1_000_000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
cacheWritesPrice: 0,
diff --git a/packages/types/src/providers/requesty.ts b/packages/types/src/providers/requesty.ts
index 3fd18c3139..d312adb397 100644
--- a/packages/types/src/providers/requesty.ts
+++ b/packages/types/src/providers/requesty.ts
@@ -9,8 +9,6 @@ export const requestyDefaultModelInfo: ModelInfo = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
diff --git a/packages/types/src/providers/sambanova.ts b/packages/types/src/providers/sambanova.ts
index dc592d180c..624a7eb8c7 100644
--- a/packages/types/src/providers/sambanova.ts
+++ b/packages/types/src/providers/sambanova.ts
@@ -19,8 +19,6 @@ export const sambaNovaModels = {
contextWindow: 16384,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.1,
outputPrice: 0.2,
description: "Meta Llama 3.1 8B Instruct model with 16K context window.",
@@ -30,8 +28,6 @@ export const sambaNovaModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.6,
outputPrice: 1.2,
description: "Meta Llama 3.3 70B Instruct model with 128K context window.",
@@ -42,8 +38,6 @@ export const sambaNovaModels = {
supportsImages: false,
supportsPromptCache: false,
supportsReasoningBudget: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 5.0,
outputPrice: 7.0,
description: "DeepSeek R1 reasoning model with 32K context window.",
@@ -53,8 +47,6 @@ export const sambaNovaModels = {
contextWindow: 32768,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 4.5,
description: "DeepSeek V3 model with 32K context window.",
@@ -64,8 +56,6 @@ export const sambaNovaModels = {
contextWindow: 32768,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 4.5,
description: "DeepSeek V3.1 model with 32K context window.",
@@ -75,8 +65,6 @@ export const sambaNovaModels = {
contextWindow: 131072,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.63,
outputPrice: 1.8,
description: "Meta Llama 4 Maverick 17B 128E Instruct model with 128K context window.",
@@ -86,8 +74,6 @@ export const sambaNovaModels = {
contextWindow: 8192,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.4,
outputPrice: 0.8,
description: "Alibaba Qwen 3 32B model with 8K context window.",
@@ -97,8 +83,6 @@ export const sambaNovaModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.22,
outputPrice: 0.59,
description: "OpenAI gpt oss 120b model with 128k context window.",
diff --git a/packages/types/src/providers/unbound.ts b/packages/types/src/providers/unbound.ts
index 16159c00b1..9715b835c9 100644
--- a/packages/types/src/providers/unbound.ts
+++ b/packages/types/src/providers/unbound.ts
@@ -7,7 +7,6 @@ export const unboundDefaultModelInfo: ModelInfo = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
diff --git a/packages/types/src/providers/vercel-ai-gateway.ts b/packages/types/src/providers/vercel-ai-gateway.ts
index 40d4f1ca50..875b87bf8b 100644
--- a/packages/types/src/providers/vercel-ai-gateway.ts
+++ b/packages/types/src/providers/vercel-ai-gateway.ts
@@ -90,7 +90,6 @@ export const vercelAiGatewayDefaultModelInfo: ModelInfo = {
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts
index 1ebce7e396..b81f985d3b 100644
--- a/packages/types/src/providers/vertex.ts
+++ b/packages/types/src/providers/vertex.ts
@@ -10,8 +10,6 @@ export const vertexModels = {
maxTokens: 65_536,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
supportsReasoningEffort: ["low", "high"],
reasoningEffort: "low",
@@ -20,16 +18,19 @@ export const vertexModels = {
defaultTemperature: 1,
inputPrice: 4.0,
outputPrice: 18.0,
+ cacheReadsPrice: 0.4,
tiers: [
{
contextWindow: 200_000,
inputPrice: 2.0,
outputPrice: 12.0,
+ cacheReadsPrice: 0.2,
},
{
contextWindow: Infinity,
inputPrice: 4.0,
outputPrice: 18.0,
+ cacheReadsPrice: 0.4,
},
],
},
@@ -37,25 +38,20 @@ export const vertexModels = {
maxTokens: 65_536,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
reasoningEffort: "medium",
supportsTemperature: true,
defaultTemperature: 1,
- inputPrice: 0.3,
- outputPrice: 2.5,
- cacheReadsPrice: 0.075,
- cacheWritesPrice: 1.0,
+ inputPrice: 0.5,
+ outputPrice: 3.0,
+ cacheReadsPrice: 0.05,
},
"gemini-2.5-flash-preview-05-20:thinking": {
maxTokens: 65_535,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 0.15,
@@ -68,8 +64,6 @@ export const vertexModels = {
maxTokens: 65_535,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 0.15,
@@ -79,8 +73,6 @@ export const vertexModels = {
maxTokens: 64_000,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 0.3,
@@ -94,8 +86,6 @@ export const vertexModels = {
maxTokens: 65_535,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: false,
inputPrice: 0.15,
@@ -108,8 +98,6 @@ export const vertexModels = {
maxTokens: 65_535,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: false,
inputPrice: 0.15,
@@ -119,8 +107,6 @@ export const vertexModels = {
maxTokens: 65_535,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 2.5,
@@ -130,8 +116,6 @@ export const vertexModels = {
maxTokens: 65_535,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 2.5,
@@ -141,8 +125,6 @@ export const vertexModels = {
maxTokens: 65_535,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 2.5,
@@ -154,8 +136,6 @@ export const vertexModels = {
maxTokens: 64_000,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 2.5,
@@ -182,8 +162,6 @@ export const vertexModels = {
maxTokens: 65_535,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: false,
inputPrice: 0,
@@ -193,8 +171,6 @@ export const vertexModels = {
maxTokens: 8192,
contextWindow: 2_097_152,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: false,
inputPrice: 0,
@@ -204,8 +180,6 @@ export const vertexModels = {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 0.15,
@@ -215,8 +189,6 @@ export const vertexModels = {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: false,
inputPrice: 0.075,
@@ -226,8 +198,6 @@ export const vertexModels = {
maxTokens: 8192,
contextWindow: 32_768,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: false,
inputPrice: 0,
@@ -237,8 +207,6 @@ export const vertexModels = {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 0.075,
@@ -248,8 +216,6 @@ export const vertexModels = {
maxTokens: 8192,
contextWindow: 2_097_152,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: false,
inputPrice: 1.25,
@@ -260,8 +226,6 @@ export const vertexModels = {
contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07'
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0, // $3 per million input tokens (≤200K context)
outputPrice: 15.0, // $15 per million output tokens (≤200K context)
cacheWritesPrice: 3.75, // $3.75 per million tokens
@@ -283,8 +247,6 @@ export const vertexModels = {
contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07'
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0, // $3 per million input tokens (≤200K context)
outputPrice: 15.0, // $15 per million output tokens (≤200K context)
cacheWritesPrice: 3.75, // $3.75 per million tokens
@@ -306,8 +268,6 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 1.0,
outputPrice: 5.0,
cacheWritesPrice: 1.25,
@@ -319,8 +279,6 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
@@ -332,8 +290,6 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 15.0,
outputPrice: 75.0,
cacheWritesPrice: 18.75,
@@ -345,8 +301,6 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 15.0,
outputPrice: 75.0,
cacheWritesPrice: 18.75,
@@ -357,8 +311,6 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
@@ -371,8 +323,6 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
@@ -383,8 +333,6 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
@@ -395,8 +343,6 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
@@ -407,8 +353,6 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 1.0,
outputPrice: 5.0,
cacheWritesPrice: 1.25,
@@ -419,8 +363,6 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 15.0,
outputPrice: 75.0,
cacheWritesPrice: 18.75,
@@ -431,8 +373,6 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.25,
outputPrice: 1.25,
cacheWritesPrice: 0.3,
@@ -442,8 +382,6 @@ export const vertexModels = {
maxTokens: 64_000,
contextWindow: 1_048_576,
supportsImages: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsPromptCache: true,
inputPrice: 0.1,
@@ -458,7 +396,6 @@ export const vertexModels = {
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.35,
outputPrice: 1.15,
description: "Meta Llama 4 Maverick 17B Instruct model, 128K context.",
@@ -468,7 +405,6 @@ export const vertexModels = {
contextWindow: 163_840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 1.35,
outputPrice: 5.4,
description: "DeepSeek R1 (0528). Available in us-central1",
@@ -478,7 +414,6 @@ export const vertexModels = {
contextWindow: 163_840,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.6,
outputPrice: 1.7,
description: "DeepSeek V3.1. Available in us-west2",
@@ -488,7 +423,6 @@ export const vertexModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.15,
outputPrice: 0.6,
description: "OpenAI gpt-oss 120B. Available in us-central1",
@@ -498,7 +432,6 @@ export const vertexModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.075,
outputPrice: 0.3,
description: "OpenAI gpt-oss 20B. Available in us-central1",
@@ -508,7 +441,6 @@ export const vertexModels = {
contextWindow: 262_144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 1.0,
outputPrice: 4.0,
description: "Qwen3 Coder 480B A35B Instruct. Available in us-south1",
@@ -518,11 +450,19 @@ export const vertexModels = {
contextWindow: 262_144,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 0.25,
outputPrice: 1.0,
description: "Qwen3 235B A22B Instruct. Available in us-south1",
},
+ "moonshotai/kimi-k2-thinking-maas": {
+ maxTokens: 16_384,
+ contextWindow: 262_144,
+ supportsPromptCache: false,
+ supportsImages: false,
+ inputPrice: 0.6,
+ outputPrice: 2.5,
+ description: "Kimi K2 Thinking Model with 256K context window.",
+ },
} as const satisfies Record
// Vertex AI models that support 1M context window beta
diff --git a/packages/types/src/providers/xai.ts b/packages/types/src/providers/xai.ts
index 23acb487aa..37e0f2d12e 100644
--- a/packages/types/src/providers/xai.ts
+++ b/packages/types/src/providers/xai.ts
@@ -11,8 +11,6 @@ export const xaiModels = {
contextWindow: 256_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.2,
outputPrice: 1.5,
cacheWritesPrice: 0.02,
@@ -26,8 +24,6 @@ export const xaiModels = {
contextWindow: 2_000_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.2,
outputPrice: 0.5,
cacheWritesPrice: 0.05,
@@ -42,8 +38,6 @@ export const xaiModels = {
contextWindow: 2_000_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.2,
outputPrice: 0.5,
cacheWritesPrice: 0.05,
@@ -58,8 +52,6 @@ export const xaiModels = {
contextWindow: 2_000_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.2,
outputPrice: 0.5,
cacheWritesPrice: 0.05,
@@ -74,8 +66,6 @@ export const xaiModels = {
contextWindow: 2_000_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.2,
outputPrice: 0.5,
cacheWritesPrice: 0.05,
@@ -90,8 +80,6 @@ export const xaiModels = {
contextWindow: 256_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 0.75,
@@ -105,8 +93,6 @@ export const xaiModels = {
contextWindow: 131072,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.3,
outputPrice: 0.5,
cacheWritesPrice: 0.07,
@@ -122,8 +108,6 @@ export const xaiModels = {
contextWindow: 131072,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 0.75,
diff --git a/packages/types/src/providers/zai.ts b/packages/types/src/providers/zai.ts
index 93cf9bb23b..41a6a808ca 100644
--- a/packages/types/src/providers/zai.ts
+++ b/packages/types/src/providers/zai.ts
@@ -16,8 +16,6 @@ export const internationalZAiModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.6,
outputPrice: 2.2,
cacheWritesPrice: 0,
@@ -30,8 +28,6 @@ export const internationalZAiModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.2,
outputPrice: 1.1,
cacheWritesPrice: 0,
@@ -44,8 +40,6 @@ export const internationalZAiModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 2.2,
outputPrice: 8.9,
cacheWritesPrice: 0,
@@ -58,8 +52,6 @@ export const internationalZAiModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 1.1,
outputPrice: 4.5,
cacheWritesPrice: 0,
@@ -71,8 +63,6 @@ export const internationalZAiModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
cacheWritesPrice: 0,
@@ -84,8 +74,6 @@ export const internationalZAiModels = {
contextWindow: 131_072,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.6,
outputPrice: 1.8,
cacheWritesPrice: 0,
@@ -93,13 +81,23 @@ export const internationalZAiModels = {
description:
"GLM-4.5V is Z.AI's multimodal visual reasoning model (image/video/text/file input), optimized for GUI tasks, grounding, and document/video understanding.",
},
+ "glm-4.6v": {
+ maxTokens: 16_384,
+ contextWindow: 131_072,
+ supportsImages: true,
+ supportsPromptCache: true,
+ inputPrice: 0.3,
+ outputPrice: 0.9,
+ cacheWritesPrice: 0,
+ cacheReadsPrice: 0.05,
+ description:
+ "GLM-4.6V is an advanced multimodal vision model with improved performance and cost-efficiency for visual understanding tasks.",
+ },
"glm-4.6": {
maxTokens: 16_384,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.6,
outputPrice: 2.2,
cacheWritesPrice: 0,
@@ -112,8 +110,6 @@ export const internationalZAiModels = {
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsReasoningEffort: ["disable", "medium"],
reasoningEffort: "medium",
preserveReasoning: true,
@@ -124,13 +120,59 @@ export const internationalZAiModels = {
description:
"GLM-4.7 is Zhipu's latest model with built-in thinking capabilities enabled by default. It provides enhanced reasoning for complex tasks while maintaining fast response times.",
},
+ "glm-4.7-flash": {
+ maxTokens: 16_384,
+ contextWindow: 200_000,
+ supportsImages: false,
+ supportsPromptCache: true,
+ inputPrice: 0,
+ outputPrice: 0,
+ cacheWritesPrice: 0,
+ cacheReadsPrice: 0,
+ description:
+ "GLM-4.7-Flash is a free, high-speed variant of GLM-4.7 offering fast responses for reasoning and coding tasks.",
+ },
+ "glm-4.7-flashx": {
+ maxTokens: 16_384,
+ contextWindow: 200_000,
+ supportsImages: false,
+ supportsPromptCache: true,
+ inputPrice: 0.07,
+ outputPrice: 0.4,
+ cacheWritesPrice: 0,
+ cacheReadsPrice: 0.01,
+ description:
+ "GLM-4.7-FlashX is an ultra-fast variant of GLM-4.7 with exceptional speed and cost-effectiveness for high-throughput applications.",
+ },
+ "glm-4.6v-flash": {
+ maxTokens: 16_384,
+ contextWindow: 131_072,
+ supportsImages: true,
+ supportsPromptCache: true,
+ inputPrice: 0,
+ outputPrice: 0,
+ cacheWritesPrice: 0,
+ cacheReadsPrice: 0,
+ description:
+ "GLM-4.6V-Flash is a free, high-speed multimodal vision model for rapid image understanding and visual reasoning tasks.",
+ },
+ "glm-4.6v-flashx": {
+ maxTokens: 16_384,
+ contextWindow: 131_072,
+ supportsImages: true,
+ supportsPromptCache: true,
+ inputPrice: 0.04,
+ outputPrice: 0.4,
+ cacheWritesPrice: 0,
+ cacheReadsPrice: 0.004,
+ description:
+ "GLM-4.6V-FlashX is an ultra-fast multimodal vision model optimized for high-speed visual processing at low cost.",
+ },
"glm-4-32b-0414-128k": {
maxTokens: 16_384,
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.1,
outputPrice: 0.1,
cacheWritesPrice: 0,
@@ -147,8 +189,6 @@ export const mainlandZAiModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.29,
outputPrice: 1.14,
cacheWritesPrice: 0,
@@ -161,8 +201,6 @@ export const mainlandZAiModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.1,
outputPrice: 0.6,
cacheWritesPrice: 0,
@@ -175,8 +213,6 @@ export const mainlandZAiModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.29,
outputPrice: 1.14,
cacheWritesPrice: 0,
@@ -189,8 +225,6 @@ export const mainlandZAiModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.1,
outputPrice: 0.6,
cacheWritesPrice: 0,
@@ -202,8 +236,6 @@ export const mainlandZAiModels = {
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
cacheWritesPrice: 0,
@@ -215,8 +247,6 @@ export const mainlandZAiModels = {
contextWindow: 131_072,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.29,
outputPrice: 0.93,
cacheWritesPrice: 0,
@@ -229,8 +259,6 @@ export const mainlandZAiModels = {
contextWindow: 204_800,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
inputPrice: 0.29,
outputPrice: 1.14,
cacheWritesPrice: 0,
@@ -243,8 +271,6 @@ export const mainlandZAiModels = {
contextWindow: 204_800,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
supportsReasoningEffort: ["disable", "medium"],
reasoningEffort: "medium",
preserveReasoning: true,
@@ -255,6 +281,66 @@ export const mainlandZAiModels = {
description:
"GLM-4.7 is Zhipu's latest model with built-in thinking capabilities enabled by default. It provides enhanced reasoning for complex tasks while maintaining fast response times.",
},
+ "glm-4.7-flash": {
+ maxTokens: 16_384,
+ contextWindow: 204_800,
+ supportsImages: false,
+ supportsPromptCache: true,
+ inputPrice: 0,
+ outputPrice: 0,
+ cacheWritesPrice: 0,
+ cacheReadsPrice: 0,
+ description:
+ "GLM-4.7-Flash is a free, high-speed variant of GLM-4.7 offering fast responses for reasoning and coding tasks.",
+ },
+ "glm-4.7-flashx": {
+ maxTokens: 16_384,
+ contextWindow: 204_800,
+ supportsImages: false,
+ supportsPromptCache: true,
+ inputPrice: 0.035,
+ outputPrice: 0.2,
+ cacheWritesPrice: 0,
+ cacheReadsPrice: 0.005,
+ description:
+ "GLM-4.7-FlashX is an ultra-fast variant of GLM-4.7 with exceptional speed and cost-effectiveness for high-throughput applications.",
+ },
+ "glm-4.6v": {
+ maxTokens: 16_384,
+ contextWindow: 131_072,
+ supportsImages: true,
+ supportsPromptCache: true,
+ inputPrice: 0.15,
+ outputPrice: 0.45,
+ cacheWritesPrice: 0,
+ cacheReadsPrice: 0.025,
+ description:
+ "GLM-4.6V is an advanced multimodal vision model with improved performance and cost-efficiency for visual understanding tasks.",
+ },
+ "glm-4.6v-flash": {
+ maxTokens: 16_384,
+ contextWindow: 131_072,
+ supportsImages: true,
+ supportsPromptCache: true,
+ inputPrice: 0,
+ outputPrice: 0,
+ cacheWritesPrice: 0,
+ cacheReadsPrice: 0,
+ description:
+ "GLM-4.6V-Flash is a free, high-speed multimodal vision model for rapid image understanding and visual reasoning tasks.",
+ },
+ "glm-4.6v-flashx": {
+ maxTokens: 16_384,
+ contextWindow: 131_072,
+ supportsImages: true,
+ supportsPromptCache: true,
+ inputPrice: 0.02,
+ outputPrice: 0.2,
+ cacheWritesPrice: 0,
+ cacheReadsPrice: 0.002,
+ description:
+ "GLM-4.6V-FlashX is an ultra-fast multimodal vision model optimized for high-speed visual processing at low cost.",
+ },
} as const satisfies Record
export const ZAI_DEFAULT_TEMPERATURE = 0.6
diff --git a/packages/types/src/skills.ts b/packages/types/src/skills.ts
new file mode 100644
index 0000000000..b50b4e6d47
--- /dev/null
+++ b/packages/types/src/skills.ts
@@ -0,0 +1,71 @@
+/**
+ * Skill metadata for discovery (loaded at startup)
+ * Only name and description are required for now
+ */
+export interface SkillMetadata {
+ name: string // Required: skill identifier
+ description: string // Required: when to use this skill
+ 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
+}
+
+/**
+ * Skill name validation constants per agentskills.io specification:
+ * https://agentskills.io/specification
+ *
+ * Name constraints:
+ * - 1-64 characters
+ * - Lowercase letters, numbers, and hyphens only
+ * - Must not start or end with a hyphen
+ * - Must not contain consecutive hyphens
+ */
+export const SKILL_NAME_MIN_LENGTH = 1
+export const SKILL_NAME_MAX_LENGTH = 64
+
+/**
+ * Regex pattern for valid skill names.
+ * Matches: lowercase letters/numbers, optionally followed by groups of hyphen + lowercase letters/numbers.
+ * This ensures no leading/trailing hyphens and no consecutive hyphens.
+ */
+export const SKILL_NAME_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
+
+/**
+ * Error codes for skill name validation.
+ * These can be mapped to translation keys in the frontend or error messages in the backend.
+ */
+export enum SkillNameValidationError {
+ Empty = "empty",
+ TooLong = "too_long",
+ InvalidFormat = "invalid_format",
+}
+
+/**
+ * Result of skill name validation.
+ */
+export interface SkillNameValidationResult {
+ valid: boolean
+ error?: SkillNameValidationError
+}
+
+/**
+ * Validate a skill name according to agentskills.io specification.
+ *
+ * @param name - The skill name to validate
+ * @returns Validation result with error code if invalid
+ */
+export function validateSkillName(name: string): SkillNameValidationResult {
+ if (!name || name.length < SKILL_NAME_MIN_LENGTH) {
+ return { valid: false, error: SkillNameValidationError.Empty }
+ }
+
+ if (name.length > SKILL_NAME_MAX_LENGTH) {
+ return { valid: false, error: SkillNameValidationError.TooLong }
+ }
+
+ if (!SKILL_NAME_REGEX.test(name)) {
+ return { valid: false, error: SkillNameValidationError.InvalidFormat }
+ }
+
+ return { valid: true }
+}
diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts
index 3f6a0aa581..00751837c2 100644
--- a/packages/types/src/task.ts
+++ b/packages/types/src/task.ts
@@ -89,9 +89,7 @@ export type TaskProviderEvents = {
*/
export interface CreateTaskOptions {
- enableDiff?: boolean
enableCheckpoints?: boolean
- fuzzyMatchThreshold?: number
consecutiveMistakeLimit?: number
experiments?: Record
initialTodos?: TodoItem[]
diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts
index f8127e6988..68ed38fe32 100644
--- a/packages/types/src/telemetry.ts
+++ b/packages/types/src/telemetry.ts
@@ -73,6 +73,7 @@ export enum TelemetryEventName {
CODE_INDEX_ERROR = "Code Index 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",
}
/**
@@ -203,6 +204,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/terminal.ts b/packages/types/src/terminal.ts
index ffa1ffe781..34f7a74e24 100644
--- a/packages/types/src/terminal.ts
+++ b/packages/types/src/terminal.ts
@@ -32,3 +32,69 @@ export const commandExecutionStatusSchema = z.discriminatedUnion("status", [
])
export type CommandExecutionStatus = z.infer
+
+/**
+ * PersistedCommandOutput
+ *
+ * Represents the result of a terminal command execution that may have been
+ * truncated and persisted to disk.
+ *
+ * When command output exceeds the configured preview threshold, the full
+ * output is saved to a disk artifact file. The LLM receives this structure
+ * which contains:
+ * - A preview of the output (for immediate display in context)
+ * - Metadata about the full output (size, truncation status)
+ * - A path to the artifact file for later retrieval via `read_command_output`
+ *
+ * ## Usage in execute_command Response
+ *
+ * The response format depends on whether truncation occurred:
+ *
+ * **Not truncated** (output fits in preview):
+ * ```json
+ * {
+ * "preview": "full output here...",
+ * "totalBytes": 1234,
+ * "artifactPath": null,
+ * "truncated": false
+ * }
+ * ```
+ *
+ * **Truncated** (output exceeded threshold):
+ * ```json
+ * {
+ * "preview": "first 4KB of output...",
+ * "totalBytes": 1048576,
+ * "artifactPath": "/path/to/tasks/123/command-output/cmd-1706119234567.txt",
+ * "truncated": true
+ * }
+ * ```
+ *
+ * @see OutputInterceptor - Creates these results during command execution
+ * @see ReadCommandOutputTool - Retrieves full content from artifact files
+ */
+export interface PersistedCommandOutput {
+ /**
+ * Preview of the command output, truncated to the preview threshold.
+ * Always contains the beginning of the output, even if truncated.
+ */
+ preview: string
+
+ /**
+ * Total size of the command output in bytes.
+ * Useful for determining if additional reads are needed.
+ */
+ totalBytes: number
+
+ /**
+ * Absolute path to the artifact file containing full output.
+ * `null` if output wasn't truncated (no artifact was created).
+ */
+ artifactPath: string | null
+
+ /**
+ * Whether the output was truncated (exceeded preview threshold).
+ * When `true`, use `read_command_output` to retrieve full content.
+ */
+ truncated: boolean
+}
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 76e03f8c80..03144055c9 100644
--- a/packages/types/src/tool.ts
+++ b/packages/types/src/tool.ts
@@ -17,6 +17,7 @@ export type ToolGroup = z.infer
export const toolNames = [
"execute_command",
"read_file",
+ "read_command_output",
"write_to_file",
"apply_diff",
"search_and_replace",
@@ -32,10 +33,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
@@ -57,48 +58,3 @@ export const toolUsageSchema = z.record(
)
export type ToolUsage = z.infer
-
-/**
- * Tool protocol constants
- */
-export const TOOL_PROTOCOL = {
- XML: "xml",
- NATIVE: "native",
-} as const
-
-/**
- * Tool protocol type for system prompt generation
- * Derived from TOOL_PROTOCOL constants to ensure type safety
- */
-export type ToolProtocol = (typeof TOOL_PROTOCOL)[keyof typeof TOOL_PROTOCOL]
-
-/**
- * Default model info properties for native tool support.
- * Used to merge with cached model info that may lack these fields.
- * Router providers (Requesty, Unbound, LiteLLM) assume all models support native tools.
- */
-export const NATIVE_TOOL_DEFAULTS = {
- supportsNativeTools: true,
- defaultToolProtocol: TOOL_PROTOCOL.NATIVE,
-} as const
-
-/**
- * Checks if the protocol is native (non-XML).
- *
- * @param protocol - The tool protocol to check
- * @returns True if protocol is native
- */
-export function isNativeProtocol(protocol: ToolProtocol): boolean {
- return protocol === TOOL_PROTOCOL.NATIVE
-}
-
-/**
- * Gets the effective protocol from settings or falls back to the default XML.
- * This function is safe to use in webview-accessible code as it doesn't depend on vscode module.
- *
- * @param toolProtocol - Optional tool protocol from settings
- * @returns The effective tool protocol (defaults to "xml")
- */
-export function getEffectiveProtocol(toolProtocol?: ToolProtocol): ToolProtocol {
- return toolProtocol || TOOL_PROTOCOL.XML
-}
diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts
index ebddd0ef64..f34102e22c 100644
--- a/packages/types/src/vscode-extension-host.ts
+++ b/packages/types/src/vscode-extension-host.ts
@@ -18,7 +18,10 @@ import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList,
import type { SerializedCustomToolDefinition } from "./custom-tool.js"
import type { GitCommit } from "./git.js"
import type { McpServer } from "./mcp.js"
+import type { SkillMetadata } from "./skills.js"
import type { ModelRecord, RouterModels } from "./model.js"
+import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js"
+import type { WorktreeIncludeStatus } from "./worktree.js"
/**
* ExtensionMessage
@@ -28,6 +31,8 @@ export interface ExtensionMessage {
type:
| "action"
| "state"
+ | "taskHistoryUpdated"
+ | "taskHistoryItemUpdated"
| "selectedImages"
| "theme"
| "workspaceUpdated"
@@ -60,7 +65,6 @@ export interface ExtensionMessage {
| "remoteBrowserEnabled"
| "ttsStart"
| "ttsStop"
- | "maxReadFileLine"
| "fileSearchResults"
| "toggleApiConfigPin"
| "acceptInput"
@@ -91,10 +95,20 @@ export interface ExtensionMessage {
| "interactionRequired"
| "browserSessionUpdate"
| "browserSessionNavigate"
- | "claudeCodeRateLimits"
| "customToolsResult"
| "modes"
| "taskWithAggregatedCosts"
+ | "openAiCodexRateLimits"
+ // Worktree response types
+ | "worktreeList"
+ | "worktreeResult"
+ | "worktreeCopyProgress"
+ | "branchList"
+ | "worktreeDefaults"
+ | "worktreeIncludeStatus"
+ | "branchWorktreeIncludeResult"
+ | "folderSelected"
+ | "skills"
text?: string
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
checkpointWarning?: {
@@ -112,7 +126,11 @@ export interface ExtensionMessage {
| "switchTab"
| "toggleAutoApprove"
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
- state?: ExtensionState
+ /**
+ * Partial state updates are allowed to reduce message size (e.g. omit large fields like taskHistory).
+ * The webview is responsible for merging.
+ */
+ state?: Partial
images?: string[]
filePaths?: string[]
openedTabs?: Array<{
@@ -150,7 +168,9 @@ export interface ExtensionMessage {
customMode?: ModeConfig
slug?: string
success?: boolean
- values?: Record // eslint-disable-line @typescript-eslint/no-explicit-any
+ /** Generic payload for extension messages that use `values` */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ values?: Record
requestId?: string
promptText?: string
results?:
@@ -183,6 +203,7 @@ export interface ExtensionMessage {
stepIndex?: number // For browserSessionNavigate: the target step index to display
tools?: SerializedCustomToolDefinition[] // For customToolsResult
modes?: { slug: string; name: string }[] // For modes response
+ skills?: SkillMetadata[] // For skills response
aggregatedCosts?: {
// For taskWithAggregatedCosts response
totalCost: number
@@ -190,6 +211,62 @@ export interface ExtensionMessage {
childrenCost: number
}
historyItem?: HistoryItem
+ taskHistory?: HistoryItem[] // For taskHistoryUpdated: full sorted task history
+ /** For taskHistoryItemUpdated: single updated/added history item */
+ taskHistoryItem?: HistoryItem
+ // Worktree response properties
+ worktrees?: Array<{
+ path: string
+ branch: string
+ commitHash: string
+ isCurrent: boolean
+ isBare: boolean
+ isDetached: boolean
+ isLocked: boolean
+ lockReason?: string
+ }>
+ isGitRepo?: boolean
+ isMultiRoot?: boolean
+ isSubfolder?: boolean
+ gitRootPath?: string
+ worktreeResult?: {
+ success: boolean
+ message: string
+ worktree?: {
+ path: string
+ branch: string
+ commitHash: string
+ isCurrent: boolean
+ isBare: boolean
+ isDetached: boolean
+ isLocked: boolean
+ lockReason?: string
+ }
+ }
+ localBranches?: string[]
+ remoteBranches?: string[]
+ currentBranch?: string
+ suggestedBranch?: string
+ suggestedPath?: string
+ worktreeIncludeExists?: boolean
+ worktreeIncludeStatus?: WorktreeIncludeStatus
+ hasGitignore?: boolean
+ gitignoreContent?: string
+ // branchWorktreeIncludeResult
+ branch?: string
+ hasWorktreeInclude?: boolean
+ // worktreeCopyProgress (size-based)
+ copyProgressBytesCopied?: number
+ copyProgressTotalBytes?: number
+ copyProgressItemName?: string
+ // folderSelected
+ path?: string
+}
+
+export interface OpenAiCodexRateLimitsMessage {
+ type: "openAiCodexRateLimits"
+ values?: OpenAiCodexRateLimitInfo
+ error?: string
}
export type ExtensionState = Pick<
@@ -226,9 +303,7 @@ export type ExtensionState = Pick<
| "ttsSpeed"
| "soundEnabled"
| "soundVolume"
- | "maxConcurrentFileReads"
- | "terminalOutputLineLimit"
- | "terminalOutputCharacterLimit"
+ | "terminalOutputPreviewSize"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"
| "terminalCommandDelay"
@@ -237,16 +312,12 @@ export type ExtensionState = Pick<
| "terminalZshOhMy"
| "terminalZshP10k"
| "terminalZdotdir"
- | "terminalCompressProgressBar"
| "diagnosticsEnabled"
- | "diffEnabled"
- | "fuzzyMatchThreshold"
| "language"
| "modeApiConfigs"
| "customModePrompts"
| "customSupportPrompts"
| "enhancementApiConfigId"
- | "condensingApiConfigId"
| "customCondensingPrompt"
| "codebaseIndexConfig"
| "codebaseIndexModels"
@@ -263,6 +334,7 @@ export type ExtensionState = Pick<
| "showQuestionsOneByOne"
| "maxGitStatusFiles"
| "requestDelaySeconds"
+ | "showWorktreesInHomeScreen"
> & {
version: string
clineMessages: ClineMessage[]
@@ -282,18 +354,16 @@ 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: string
customModes: ModeConfig[]
- toolRequirements?: Record // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled)
+ toolRequirements?: Record // Map of tool names to their requirements (e.g. {"apply_diff": true})
cwd?: string // Current working directory
telemetrySetting: TelemetrySetting
@@ -333,7 +403,6 @@ export type ExtensionState = Pick<
remoteControlEnabled: boolean
taskSyncEnabled: boolean
featureRoomoteControlEnabled: boolean
- claudeCodeIsAuthenticated?: boolean
openAiCodexIsAuthenticated?: boolean
debug?: boolean
}
@@ -430,7 +499,6 @@ export interface WebviewMessage {
| "deleteMessageConfirm"
| "submitEditedMessage"
| "editMessageConfirm"
- | "enableMcpServerCreation"
| "remoteControlEnabled"
| "taskSyncEnabled"
| "searchCommits"
@@ -462,8 +530,6 @@ export interface WebviewMessage {
| "cloudLandingPageSignIn"
| "rooCloudSignOut"
| "rooCloudManualUrl"
- | "claudeCodeSignIn"
- | "claudeCodeSignOut"
| "openAiCodexSignIn"
| "openAiCodexSignOut"
| "switchOrganization"
@@ -518,11 +584,29 @@ export interface WebviewMessage {
| "openDebugApiHistory"
| "openDebugUiHistory"
| "downloadErrorDiagnostics"
- | "requestClaudeCodeRateLimits"
+ | "requestOpenAiCodexRateLimits"
| "refreshCustomTools"
| "requestModes"
| "switchMode"
| "debugSetting"
+ // Worktree messages
+ | "listWorktrees"
+ | "createWorktree"
+ | "deleteWorktree"
+ | "switchWorktree"
+ | "getAvailableBranches"
+ | "getWorktreeDefaults"
+ | "getWorktreeIncludeStatus"
+ | "checkBranchWorktreeInclude"
+ | "createWorktreeInclude"
+ | "checkoutBranch"
+ | "browseForWorktreePath"
+ // Skills messages
+ | "requestSkills"
+ | "createSkill"
+ | "deleteSkill"
+ | "moveSkill"
+ | "openSkillFile"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@@ -547,6 +631,7 @@ export interface WebviewMessage {
promptMode?: string | "enhance"
customPrompt?: PromptComponent
dataUrls?: string[]
+ /** Generic payload for webview messages that use `values` */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
values?: Record
query?: string
@@ -555,7 +640,11 @@ export interface WebviewMessage {
modeConfig?: ModeConfig
timeout?: number
payload?: WebViewMessagePayload
- source?: "global" | "project"
+ 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[]
hasSystemPromptOverride?: boolean
@@ -611,6 +700,18 @@ export interface WebviewMessage {
codebaseIndexOpenRouterApiKey?: string
}
updatedSettings?: RooCodeSettings
+ // Worktree properties
+ worktreePath?: string
+ worktreeBranch?: string
+ worktreeBaseBranch?: string
+ worktreeCreateNewBranch?: boolean
+ worktreeForce?: boolean
+ worktreeNewWindow?: boolean
+ worktreeIncludeContent?: string
+}
+
+export interface RequestOpenAiCodexRateLimitsMessage {
+ type: "requestOpenAiCodexRateLimits"
}
export const checkoutDiffPayloadSchema = z.object({
@@ -686,7 +787,7 @@ export interface ClineSayTool {
| "newFileCreated"
| "codebaseSearch"
| "readFile"
- | "fetchInstructions"
+ | "readCommandOutput"
| "listFilesTopLevel"
| "listFilesRecursive"
| "searchFiles"
@@ -697,7 +798,14 @@ export interface ClineSayTool {
| "imageGenerated"
| "runSlashCommand"
| "updateTodoList"
+ | "skill"
path?: string
+ // For readCommandOutput
+ readStart?: number
+ readEnd?: number
+ totalBytes?: number
+ searchPattern?: string
+ matchCount?: number
diff?: string
content?: string
// Unified diff statistics computed by the extension
@@ -710,6 +818,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
@@ -737,6 +846,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/packages/types/src/worktree.ts b/packages/types/src/worktree.ts
new file mode 100644
index 0000000000..16cbb163ed
--- /dev/null
+++ b/packages/types/src/worktree.ts
@@ -0,0 +1,99 @@
+/**
+ * Worktree Types
+ *
+ * Platform-agnostic type definitions for git worktree operations.
+ * These types are decoupled from VSCode and can be used by any consumer.
+ */
+
+/**
+ * Represents a git worktree
+ */
+export interface Worktree {
+ /** Absolute path to the worktree directory */
+ path: string
+ /** Branch name - empty string if detached HEAD */
+ branch: string
+ /** Current commit hash */
+ commitHash: string
+ /** Whether this is the current worktree (matches cwd) */
+ isCurrent: boolean
+ /** Whether this is the bare/main repository */
+ isBare: boolean
+ /** Whether HEAD is detached (not on a branch) */
+ isDetached: boolean
+ /** Whether the worktree is locked */
+ isLocked: boolean
+ /** Reason for lock if locked */
+ lockReason?: string
+}
+
+/**
+ * Result of a worktree operation (create, delete, etc.)
+ */
+export interface WorktreeResult {
+ /** Whether the operation succeeded */
+ success: boolean
+ /** Human-readable message describing the result */
+ message: string
+ /** The worktree that was affected (if applicable) */
+ worktree?: Worktree
+}
+
+/**
+ * Branch information for worktree creation
+ */
+export interface BranchInfo {
+ /** Local branches available */
+ localBranches: string[]
+ /** Remote branches available */
+ remoteBranches: string[]
+ /** Currently checked out branch */
+ currentBranch: string
+}
+
+/**
+ * Options for creating a worktree
+ */
+export interface CreateWorktreeOptions {
+ /** Path where the worktree will be created */
+ path: string
+ /** Branch name to checkout or create */
+ branch?: string
+ /** Base branch to create new branch from */
+ baseBranch?: string
+ /** If true, create a new branch; if false, checkout existing branch */
+ createNewBranch?: boolean
+}
+
+/**
+ * Status of .worktreeinclude file
+ */
+export interface WorktreeIncludeStatus {
+ /** Whether .worktreeinclude exists in the directory */
+ exists: boolean
+ /** Whether .gitignore exists in the directory */
+ hasGitignore: boolean
+ /** Content of .gitignore (for creating .worktreeinclude) */
+ gitignoreContent?: string
+}
+
+/**
+ * Response for listWorktrees handler
+ */
+export interface WorktreeListResponse {
+ worktrees: Worktree[]
+ isGitRepo: boolean
+ error?: string
+ isMultiRoot: boolean
+ isSubfolder: boolean
+ gitRootPath: string
+}
+
+/**
+ * Response for worktree defaults
+ */
+export interface WorktreeDefaultsResponse {
+ suggestedBranch: string
+ suggestedPath: string
+ error?: string
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 177d0b3e5a..01f1fb5f41 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,7 @@ overrides:
glob: '>=11.1.0'
'@types/react': ^18.3.23
'@types/react-dom': ^18.3.5
+ zod: 3.25.76
importers:
@@ -103,6 +104,12 @@ importers:
commander:
specifier: ^12.1.0
version: 12.1.0
+ cross-spawn:
+ specifier: ^7.0.6
+ version: 7.0.6
+ execa:
+ specifier: ^9.5.2
+ version: 9.6.0
fuzzysort:
specifier: ^3.1.0
version: 3.1.0
@@ -142,7 +149,7 @@ importers:
version: 6.0.1
tsup:
specifier: ^8.4.0
- version: 8.5.0(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0)
+ version: 8.5.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0)
vitest:
specifier: ^3.2.3
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
@@ -237,8 +244,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.76.1(react@18.3.1)
@@ -261,8 +268,8 @@ importers:
specifier: ^0.518.0
version: 0.518.0(react@18.3.1)
next:
- specifier: ~15.2.8
- version: 15.2.8(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)
@@ -297,8 +304,8 @@ importers:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
zod:
- specifier: ^3.25.61
- version: 3.25.61
+ specifier: 3.25.76
+ version: 3.25.76
devDependencies:
'@roo-code/config-eslint':
specifier: workspace:^
@@ -331,23 +338,26 @@ importers:
apps/web-roo-code:
dependencies:
'@radix-ui/react-dialog':
- specifier: ^1.1.14
- version: 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(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.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot':
- specifier: ^1.2.3
- version: 1.2.3(@types/react@18.3.23)(react@18.3.1)
+ specifier: ^1.2.4
+ version: 1.2.4(@types/react@18.3.23)(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
- version: 5.80.2(react@18.3.1)
+ 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
@@ -364,20 +374,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.2.2)(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.2.2)(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.8(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.249.2
+ specifier: ^1.336.4
+ version: 1.336.4
react:
specifier: ^18.3.1
version: 18.3.1
@@ -403,8 +413,8 @@ importers:
specifier: ^4.0.1
version: 4.0.1
tailwind-merge:
- specifier: ^3.3.0
- version: 3.3.0
+ specifier: ^3.4.0
+ version: 3.4.0
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17)
@@ -412,8 +422,8 @@ importers:
specifier: ^6.1.86
version: 6.1.86
zod:
- specifier: ^3.25.61
- version: 3.25.61
+ specifier: 3.25.76
+ version: 3.25.76
devDependencies:
'@roo-code/config-eslint':
specifier: workspace:^
@@ -422,8 +432,8 @@ importers:
specifier: workspace:^
version: link:../../packages/config-typescript
'@tailwindcss/typography':
- specifier: ^0.5.16
- version: 0.5.16(tailwindcss@3.4.17)
+ specifier: ^0.5.19
+ version: 0.5.19(tailwindcss@3.4.17)
'@types/node':
specifier: 20.x
version: 20.17.57
@@ -434,14 +444,14 @@ importers:
specifier: ^18.3.5
version: 18.3.7(@types/react@18.3.23)
autoprefixer:
- specifier: ^10.4.21
- version: 10.4.21(postcss@8.5.4)
+ 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.8(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
- version: 8.5.4
+ specifier: ^8.5.6
+ version: 8.5.6
tailwindcss:
specifier: ^3.4.17
version: 3.4.17
@@ -449,8 +459,8 @@ importers:
packages/build:
dependencies:
zod:
- specifier: ^3.25.61
- version: 3.25.61
+ specifier: 3.25.76
+ version: 3.25.76
devDependencies:
'@roo-code/config-eslint':
specifier: workspace:^
@@ -483,7 +493,7 @@ importers:
specifier: ^4.8.1
version: 4.8.1
zod:
- specifier: ^3.25.76
+ specifier: 3.25.76
version: 3.25.76
devDependencies:
'@roo-code/config-eslint':
@@ -551,11 +561,14 @@ importers:
execa:
specifier: ^9.5.2
version: 9.6.0
+ ignore:
+ specifier: ^7.0.3
+ version: 7.0.5
openai:
specifier: ^5.12.2
version: 5.12.2(ws@8.18.3)(zod@3.25.76)
zod:
- specifier: ^3.25.61
+ specifier: 3.25.76
version: 3.25.76
devDependencies:
'@roo-code/config-eslint':
@@ -584,7 +597,7 @@ importers:
version: 0.13.0
drizzle-orm:
specifier: ^0.44.1
- version: 0.44.1(@libsql/client@0.15.8)(better-sqlite3@11.10.0)(gel@2.1.0)(postgres@3.4.7)
+ version: 0.44.1(@libsql/client@0.15.8)(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(gel@2.1.0)(postgres@3.4.7)
execa:
specifier: ^9.6.0
version: 9.6.0
@@ -610,8 +623,8 @@ importers:
specifier: ^5.5.5
version: 5.5.5
zod:
- specifier: ^3.25.61
- version: 3.25.61
+ specifier: 3.25.76
+ version: 3.25.76
devDependencies:
'@roo-code/config-eslint':
specifier: workspace:^
@@ -672,8 +685,8 @@ importers:
specifier: ^5.0.0
version: 5.1.1
zod:
- specifier: ^3.25.61
- version: 3.25.61
+ specifier: 3.25.76
+ version: 3.25.76
devDependencies:
'@roo-code/config-eslint':
specifier: workspace:^
@@ -694,8 +707,8 @@ importers:
packages/types:
dependencies:
zod:
- specifier: ^3.25.61
- version: 3.25.61
+ specifier: 3.25.76
+ version: 3.25.76
devDependencies:
'@roo-code/config-eslint':
specifier: workspace:^
@@ -711,7 +724,7 @@ importers:
version: 16.3.0
tsup:
specifier: ^8.4.0
- version: 8.5.0(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0)
+ version: 8.5.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0)
vitest:
specifier: ^3.2.3
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
@@ -733,6 +746,18 @@ importers:
src:
dependencies:
+ '@ai-sdk/cerebras':
+ specifier: ^1.0.0
+ version: 1.0.35(zod@3.25.76)
+ '@ai-sdk/deepseek':
+ specifier: ^2.0.14
+ version: 2.0.14(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
@@ -756,7 +781,7 @@ importers:
version: 1.2.0
'@mistralai/mistralai':
specifier: ^1.9.18
- version: 1.9.18(zod@3.25.61)
+ version: 1.9.18(zod@3.25.76)
'@modelcontextprotocol/sdk':
specifier: 1.12.0
version: 1.12.0
@@ -844,6 +869,9 @@ importers:
isbinaryfile:
specifier: ^5.0.2
version: 5.0.4
+ json-stream-stringify:
+ specifier: ^3.1.6
+ version: 3.1.6
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
@@ -867,7 +895,7 @@ importers:
version: 0.5.17
openai:
specifier: ^5.12.2
- version: 5.12.2(ws@8.18.3)(zod@3.25.61)
+ version: 5.12.2(ws@8.18.3)(zod@3.25.76)
os-name:
specifier: ^6.0.0
version: 6.1.0
@@ -974,9 +1002,15 @@ importers:
specifier: ^2.8.0
version: 2.8.0
zod:
- specifier: 3.25.61
- version: 3.25.61
+ specifier: 3.25.76
+ version: 3.25.76
devDependencies:
+ '@ai-sdk/openai-compatible':
+ specifier: ^1.0.0
+ version: 1.0.31(zod@3.25.76)
+ '@openrouter/ai-sdk-provider':
+ specifier: ^2.0.4
+ version: 2.1.1(ai@6.0.57(zod@3.25.76))(zod@3.25.76)
'@roo-code/build':
specifier: workspace:^
version: link:../packages/build
@@ -1049,6 +1083,9 @@ importers:
'@vscode/vsce':
specifier: 3.3.2
version: 3.3.2
+ ai:
+ specifier: ^6.0.0
+ version: 6.0.57(zod@3.25.76)
esbuild-wasm:
specifier: ^0.25.0
version: 0.25.12
@@ -1075,7 +1112,7 @@ importers:
version: 6.0.1
tsup:
specifier: ^8.4.0
- version: 8.5.0(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0)
+ version: 8.5.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0)
tsx:
specifier: ^4.19.3
version: 4.19.4
@@ -1084,7 +1121,7 @@ importers:
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
zod-to-ts:
specifier: ^1.2.0
- version: 1.2.0(typescript@5.8.3)(zod@3.25.61)
+ version: 1.2.0(typescript@5.8.3)(zod@3.25.76)
webview-ui:
dependencies:
@@ -1115,6 +1152,9 @@ importers:
'@radix-ui/react-progress':
specifier: ^1.1.2
version: 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-radio-group':
+ specifier: ^1.3.8
+ version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-select':
specifier: ^2.1.6
version: 2.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1211,6 +1251,9 @@ importers:
react:
specifier: ^18.3.1
version: 18.3.1
+ react-compiler-runtime:
+ specifier: ^1.0.0
+ version: 1.0.0(react@18.3.1)
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
@@ -1287,8 +1330,8 @@ importers:
specifier: ^0.2.2
version: 0.2.2(@types/react@18.3.23)(react@18.3.1)
zod:
- specifier: ^3.25.61
- version: 3.25.61
+ specifier: 3.25.76
+ version: 3.25.76
devDependencies:
'@roo-code/config-eslint':
specifier: workspace:^
@@ -1338,6 +1381,9 @@ importers:
'@vitest/ui':
specifier: ^3.2.3
version: 3.2.4(vitest@3.2.4)
+ babel-plugin-react-compiler:
+ specifier: ^1.0.0
+ version: 1.0.0
identity-obj-proxy:
specifier: ^3.0.0
version: 3.0.0
@@ -1356,6 +1402,78 @@ packages:
'@adobe/css-tools@4.4.2':
resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
+ '@ai-sdk/cerebras@1.0.35':
+ resolution: {integrity: sha512-JrNdMYptrOUjNthibgBeAcBjZ/H+fXb49sSrWhOx5Aq8eUcrYvwQ2DtSAi8VraHssZu78NAnBMrgFWSUOTXFxw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: 3.25.76
+
+ '@ai-sdk/deepseek@2.0.14':
+ resolution: {integrity: sha512-1vXh8sVwRJYd1JO57qdy1rACucaNLDoBRCwOER3EbPgSF2vNVPcdJywGutA01Bhn7Cta+UJQ+k5y/yzMAIpP2w==}
+ 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.25':
+ resolution: {integrity: sha512-j0AQeA7hOVqwImykQlganf/Euj3uEXf0h3G0O4qKTDpEwE+EZGIPnVimCWht5W91lAetPZSfavDyvfpuPDd2PQ==}
+ 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@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'}
+ peerDependencies:
+ zod: 3.25.76
+
+ '@ai-sdk/provider-utils@4.0.10':
+ resolution: {integrity: sha512-VeDAiCH+ZK8Xs4hb9Cw7pHlujWNL52RKe8TExOkrw6Ir1AmfajBZTb9XUdKOZO08RwQElIKA8+Ltm+Gqfo8djQ==}
+ engines: {node: '>=18'}
+ 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'}
+
+ '@ai-sdk/provider@3.0.5':
+ 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.3':
resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==}
engines: {node: '>=18'}
@@ -1819,6 +1937,9 @@ packages:
'@emnapi/runtime@1.4.3':
resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==}
+ '@emnapi/runtime@1.8.1':
+ resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
+
'@emnapi/wasi-threads@1.0.2':
resolution: {integrity: sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==}
@@ -2098,107 +2219,139 @@ packages:
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
- '@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]
@@ -2374,7 +2527,7 @@ packages:
'@mistralai/mistralai@1.9.18':
resolution: {integrity: sha512-D/vNAGEvWMsg95tzgLTg7pPnW9leOPyH+nh1Os05NwxVPbUykoYgMAwOEX7J46msahWdvZ4NQQuxUXIUV2P6dg==}
peerDependencies:
- zod: '>= 3'
+ zod: 3.25.76
'@mixmark-io/domino@2.2.0':
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
@@ -2399,56 +2552,56 @@ packages:
'@next/env@13.5.11':
resolution: {integrity: sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==}
- '@next/env@15.2.8':
- resolution: {integrity: sha512-TaEsAki14R7BlgywA05t2PFYfwZiNlGUHyIQHVyloXX3y+Dm0HUITe5YwTkjtuOQuDhuuLotNEad4VtnmE11Uw==}
+ '@next/env@16.1.6':
+ resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
'@next/eslint-plugin-next@15.3.2':
resolution: {integrity: sha512-ijVRTXBgnHT33aWnDtmlG+LJD+5vhc9AKTJPquGG5NKXjpKNjc62woIhFtrAcWdBobt8kqjCoaJ0q6sDQoX7aQ==}
- '@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]
@@ -2573,6 +2726,85 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
+ '@openrouter/ai-sdk-provider@2.1.1':
+ resolution: {integrity: sha512-UypPbVnSExxmG/4Zg0usRiit3auvQVrjUXSyEhm0sZ9GQnW/d8p/bKgCk2neh1W5YyRSo7PNQvCrAEBHZnqQkQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ ai: ^6.0.0
+ zod: 3.25.76
+
+ '@opentelemetry/api-logs@0.208.0':
+ resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
+ engines: {node: '>=8.0.0'}
+
+ '@opentelemetry/api@1.9.0':
+ resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
+ engines: {node: '>=8.0.0'}
+
+ '@opentelemetry/core@2.2.0':
+ resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+
+ '@opentelemetry/core@2.5.0':
+ resolution: {integrity: sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+
+ '@opentelemetry/exporter-logs-otlp-http@0.208.0':
+ resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
+ '@opentelemetry/otlp-exporter-base@0.208.0':
+ resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
+ '@opentelemetry/otlp-transformer@0.208.0':
+ resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
+ '@opentelemetry/resources@2.2.0':
+ resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/resources@2.5.0':
+ resolution: {integrity: sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/sdk-logs@0.208.0':
+ resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.4.0 <1.10.0'
+
+ '@opentelemetry/sdk-metrics@2.2.0':
+ resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.9.0 <1.10.0'
+
+ '@opentelemetry/sdk-trace-base@2.2.0':
+ resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/semantic-conventions@1.39.0':
+ resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==}
+ engines: {node: '>=14'}
+
'@oxc-resolver/binding-darwin-arm64@11.2.0':
resolution: {integrity: sha512-ruKLkS+Dm/YIJaUhzEB7zPI+jh3EXxu0QnNV8I7t9jf0lpD2VnltuyRbhrbJEkksklZj//xCMyFFsILGjiU2Mg==}
cpu: [arm64]
@@ -2644,6 +2876,42 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+ '@posthog/core@1.17.0':
+ resolution: {integrity: sha512-8pDNL+/u9ojzXloA5wILVDXBCV5daJ7w2ipCALQlEEZmL752cCKhRpbyiHn3tjKXh3Hy6aOboJneYa1JdlVHrQ==}
+
+ '@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==}
+
+ '@protobufjs/base64@1.1.2':
+ resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
+
+ '@protobufjs/codegen@2.0.4':
+ resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
+
+ '@protobufjs/eventemitter@1.1.0':
+ resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
+
+ '@protobufjs/fetch@1.1.0':
+ resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
+
+ '@protobufjs/float@1.0.2':
+ resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
+
+ '@protobufjs/inquire@1.1.0':
+ resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
+
+ '@protobufjs/path@1.1.2':
+ resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
+
+ '@protobufjs/pool@1.1.0':
+ resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
+
+ '@protobufjs/utf8@1.1.0':
+ resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
+
'@puppeteer/browsers@2.10.5':
resolution: {integrity: sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==}
engines: {node: '>=18'}
@@ -2795,8 +3063,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-dialog@1.1.14':
- resolution: {integrity: sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==}
+ '@radix-ui/react-dialog@1.1.15':
+ resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
peerDependencies:
'@types/react': ^18.3.23
'@types/react-dom': ^18.3.5
@@ -2817,19 +3085,6 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-dismissable-layer@1.1.10':
- resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==}
- 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-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
@@ -2878,6 +3133,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-focus-guards@1.1.3':
+ resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
+ peerDependencies:
+ '@types/react': ^18.3.23
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-focus-scope@1.1.6':
resolution: {integrity: sha512-r9zpYNUQY+2jWHWZGyddQLL9YHkM/XvSFHVcWs7bdVuxMAnCwTAuy6Pf47Z4nw7dYcUou1vg/VgjjrrH03VeBw==}
peerDependencies:
@@ -2944,6 +3208,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.13':
resolution: {integrity: sha512-84uqQV3omKDR076izYgcha6gdpN8m3z6w/AeJ83MSBJYVG/AbOHdLjAgsPZkeC/kt+k64moXFCnio8BbqXszlw==}
peerDependencies:
@@ -3074,6 +3351,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-radio-group@1.3.8':
+ resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
+ 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-roving-focus@1.1.10':
resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==}
peerDependencies:
@@ -3087,6 +3377,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-roving-focus@1.1.11':
+ resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
+ 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-roving-focus@1.1.9':
resolution: {integrity: sha512-ZzrIFnMYHHCNqSNCsuN6l7wlewBEq0O0BCSBkabJMFXVO51LRUTq71gLP1UxFvmrXElqmPjA5VX7IqC9VpazAQ==}
peerDependencies:
@@ -3170,6 +3473,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-slot@1.2.4':
+ resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
+ peerDependencies:
+ '@types/react': ^18.3.23
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-tabs@1.1.12':
resolution: {integrity: sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==}
peerDependencies:
@@ -3442,6 +3754,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==}
@@ -3801,12 +4116,12 @@ packages:
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@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==}
@@ -3983,8 +4298,8 @@ packages:
'@tailwindcss/postcss@4.1.8':
resolution: {integrity: sha512-vB/vlf7rIky+w94aWMw34bWW1ka6g6C3xIOdICKX2GC0VcLtL6fhlLiafF0DVIwa9V6EHz8kbWMkS2s2QvvNlw==}
- '@tailwindcss/typography@0.5.16':
- resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==}
+ '@tailwindcss/typography@0.5.19':
+ resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
@@ -3996,16 +4311,16 @@ packages:
'@tanstack/query-core@5.76.0':
resolution: {integrity: sha512-FN375hb8ctzfNAlex5gHI6+WDXTNpe0nbxp/d2YJtnP+IBM6OUm7zcaoCW6T63BawGOYZBbKC0iPvr41TteNVg==}
- '@tanstack/query-core@5.80.2':
- resolution: {integrity: sha512-g2Es97uwFk7omkWiH9JmtLWSA8lTUFVseIyzqbjqJEEx7qN+Hg6jbBdDvelqtakamppaJtGORQ64hEJ5S6ojSg==}
+ '@tanstack/query-core@5.90.20':
+ resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==}
'@tanstack/react-query@5.76.1':
resolution: {integrity: sha512-YxdLZVGN4QkT5YT1HKZQWiIlcgauIXEIsMOTSjvyD5wLYK8YVvKZUPAysMqossFJJfDpJW3pFn7WNZuPOqq+fw==}
peerDependencies:
react: ^18 || ^19
- '@tanstack/react-query@5.80.2':
- resolution: {integrity: sha512-LfA0SVheJBOqC8RfJw/JbOW3yh2zuONQeWU5Prjm7yjUGUONeOedky1Bj39Cfj8MRdXrZV+DxNT7/DN/M907lQ==}
+ '@tanstack/react-query@5.90.20':
+ resolution: {integrity: sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==}
peerDependencies:
react: ^18 || ^19
@@ -4417,10 +4732,14 @@ 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':
+ resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
+ engines: {node: '>= 20'}
+
'@vitejs/plugin-react@4.4.1':
resolution: {integrity: sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -4573,6 +4892,12 @@ packages:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
+ ai@6.0.57:
+ resolution: {integrity: sha512-5wYcMQmOaNU71wGv4XX1db3zvn4uLjLbTKIo6cQZPWOJElA0882XI7Eawx6TCd5jbjOvKMIP+KLWbpVomAFT2g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: 3.25.76
+
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@@ -4727,8 +5052,8 @@ packages:
resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
- autoprefixer@10.4.21:
- resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==}
+ autoprefixer@10.4.23:
+ resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
@@ -4747,6 +5072,9 @@ packages:
b4a@1.6.7:
resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==}
+ babel-plugin-react-compiler@1.0.0:
+ resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==}
+
bail@1.0.5:
resolution: {integrity: sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==}
@@ -4793,6 +5121,10 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+ baseline-browser-mapping@2.9.19:
+ resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
+ hasBin: true
+
basic-ftp@5.0.5:
resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==}
engines: {node: '>=10.0.0'}
@@ -4856,6 +5188,11 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ browserslist@4.28.1:
+ resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
@@ -4893,10 +5230,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'}
@@ -4941,8 +5274,8 @@ packages:
camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
- caniuse-lite@1.0.30001718:
- resolution: {integrity: sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==}
+ caniuse-lite@1.0.30001766:
+ resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -5136,17 +5469,10 @@ 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-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true
- 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==}
@@ -5649,6 +5975,10 @@ packages:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
@@ -5717,6 +6047,9 @@ packages:
dompurify@3.2.6:
resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==}
+ dompurify@3.3.1:
+ resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
+
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@@ -5860,6 +6193,9 @@ packages:
electron-to-chromium@1.5.152:
resolution: {integrity: sha512-xBOfg/EBaIlVsHipHl2VdTPJRSvErNUaqW8ejTq5OlOlIYx1wOllCHsAvAIrr55jD1IYEfdR86miUEt8H5IeJg==}
+ electron-to-chromium@1.5.283:
+ resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==}
+
embla-carousel-auto-scroll@8.6.0:
resolution: {integrity: sha512-WT9fWhNXFpbQ6kP+aS07oF5IHYLZ1Dx4DkwgCY8Hv2ZyYd2KMCPfMV1q/cA3wFGuLO7GMgKiySLX90/pQkcOdQ==}
peerDependencies:
@@ -5883,6 +6219,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.4.0:
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
@@ -6152,6 +6492,10 @@ packages:
resolution: {integrity: sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==}
engines: {node: '>=18.0.0'}
+ eventsource-parser@3.0.6:
+ resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
+ engines: {node: '>=18.0.0'}
+
eventsource@3.0.7:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
@@ -6394,11 +6738,11 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
- fraction.js@4.3.7:
- resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
+ 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
@@ -6918,9 +7262,6 @@ packages:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
- is-arrayish@0.3.2:
- resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
-
is-async-function@2.1.1:
resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
engines: {node: '>= 0.4'}
@@ -7271,9 +7612,16 @@ packages:
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+ json-schema@0.4.0:
+ resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
+
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+ json-stream-stringify@3.1.6:
+ resolution: {integrity: sha512-x7fpwxOkbhFCaJDJ8vb1fBY3DdSa4AlITaz+HHILQJzdPMnHEFjxPwVUi1ALIbcIxDE0PNe/0i7frnY8QnBQog==}
+ engines: {node: '>=7.10.1'}
+
json-stringify-safe@5.0.1:
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
@@ -7551,9 +7899,6 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
- lodash.castarray@4.4.0:
- resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
-
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
@@ -7639,6 +7984,9 @@ packages:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'}
+ long@5.3.2:
+ resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
+
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -7685,6 +8033,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
@@ -8018,11 +8371,11 @@ packages:
peerDependencies:
tslib: ^2.0.1
- motion-dom@12.16.0:
- resolution: {integrity: sha512-Z2nGwWrrdH4egLEtgYMCEN4V2qQt1qxlKy/uV7w691ztyA41Q5Rbn0KNGbsNVDZr9E8PD2IOQ3hSccRnB6xWzw==}
+ motion-dom@12.29.2:
+ resolution: {integrity: sha512-/k+NuycVV8pykxyiTCoFzIVLA95Nb1BFIVvfSu9L50/6K6qNeAYtkxXILy/LRutt7AzaYDc2myj0wkCVVYAPPA==}
- motion-utils@12.12.1:
- resolution: {integrity: sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==}
+ motion-utils@12.29.2:
+ resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
@@ -8083,13 +8436,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.8:
- resolution: {integrity: sha512-pe2trLKZTdaCuvNER0S9Wp+SP2APf7SfFmyUP9/w1SFA2UqmW0u+IsxCKkiky3n6um7mryaQIlgiDnKrf1ZwIw==}
- 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
@@ -8147,6 +8500,9 @@ packages:
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
+ node-releases@2.0.27:
+ resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+
noms@0.0.0:
resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==}
@@ -8154,10 +8510,6 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
- normalize-range@0.1.2:
- resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
- engines: {node: '>=0.10.0'}
-
npm-normalize-package-bin@4.0.0:
resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -8265,7 +8617,7 @@ packages:
hasBin: true
peerDependencies:
ws: ^8.18.0
- zod: ^3.23.8
+ zod: 3.25.76
peerDependenciesMeta:
ws:
optional: true
@@ -8590,6 +8942,10 @@ packages:
resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==}
engines: {node: ^10 || ^12 || >=14}
+ postcss@8.5.6:
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ engines: {node: ^10 || ^12 || >=14}
+
postgres@3.4.7:
resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==}
engines: {node: '>=12'}
@@ -8605,16 +8961,8 @@ packages:
rrweb-snapshot:
optional: true
- posthog-js@1.249.2:
- resolution: {integrity: sha512-OMXCO/IfcJBjYTuebVynMbp8Kq329yKEQSCAnkqLmi8W2Bt5bi7S5xxMwDM3Pm7818Uh0C40XMG3rAtYozId6Q==}
- peerDependencies:
- '@rrweb/types': 2.0.0-alpha.17
- rrweb-snapshot: 2.0.0-alpha.17
- peerDependenciesMeta:
- '@rrweb/types':
- optional: true
- rrweb-snapshot:
- optional: true
+ posthog-js@1.336.4:
+ resolution: {integrity: sha512-NX81XaqOjS/gue3UsbAAuJxi6vD0AGy1HUvywBIhAArCwbTXKS04NhEFwUcYJdrmwXUf94MntEIWGoc1pTFDtg==}
posthog-node@5.1.1:
resolution: {integrity: sha512-6VISkNdxO24ehXiDA4dugyCSIV7lpGVaEu5kn/dlAj+SJ1lgcDru9PQ8p/+GSXsXVxohd1t7kHL2JKc9NoGb0w==}
@@ -8623,6 +8971,9 @@ packages:
preact@10.26.6:
resolution: {integrity: sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==}
+ preact@10.28.2:
+ resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==}
+
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
@@ -8691,6 +9042,10 @@ packages:
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+ protobufjs@7.5.4:
+ resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
+ engines: {node: '>=12.0.0'}
+
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -8741,6 +9096,9 @@ packages:
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
+ query-selector-shadow-dom@1.0.1:
+ resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -8759,6 +9117,11 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
+ react-compiler-runtime@1.0.0:
+ resolution: {integrity: sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w==}
+ peerDependencies:
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental
+
react-cookie-consent@9.0.0:
resolution: {integrity: sha512-Blyj+m+Zz7SFHYqT18p16EANgnSg2sIyU6Yp3vk83AnOnSW7qnehPkUe4+8+qxztJrNmCH5GP+VHsWzAKVOoZA==}
engines: {node: '>=10'}
@@ -9151,8 +9514,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.4.1:
@@ -9259,8 +9622,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:
@@ -9321,9 +9684,6 @@ packages:
resolution: {integrity: sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==}
engines: {node: '>=10'}
- simple-swizzle@0.2.2:
- resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
-
sirv@3.0.1:
resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==}
engines: {node: '>=18'}
@@ -9457,10 +9817,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.22.0:
resolution: {integrity: sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==}
@@ -9664,6 +10020,9 @@ packages:
tailwind-merge@3.3.0:
resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==}
+ tailwind-merge@3.4.0:
+ resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
+
tailwindcss-animate@1.0.7:
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
peerDependencies:
@@ -9697,6 +10056,7 @@ packages:
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
+ deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
term-size@2.2.1:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
@@ -10087,6 +10447,12 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -10369,6 +10735,9 @@ packages:
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
+ web-vitals@5.1.0:
+ resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==}
+
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -10622,9 +10991,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'}
@@ -10636,25 +11002,19 @@ packages:
zod-to-json-schema@3.24.5:
resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==}
peerDependencies:
- zod: ^3.24.1
+ zod: 3.25.76
zod-to-ts@1.2.0:
resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==}
peerDependencies:
typescript: ^4.9.4 || ^5.0.2
- zod: ^3
+ zod: 3.25.76
zod-validation-error@3.4.1:
resolution: {integrity: sha512-1KP64yqDPQ3rupxNv7oXhf7KdhHHgaqbKuspVoiN93TT0xrBjql+Svjkdjq/Qh/7GSMmgQs3AfvBT0heE35thw==}
engines: {node: '>=18.0.0'}
peerDependencies:
- zod: ^3.24.4
-
- zod@3.23.8:
- resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
-
- zod@3.25.61:
- resolution: {integrity: sha512-fzfJgUw78LTNnHujj9re1Ov/JJQkRZZGDMcYqSx7Hp4rPOkKywaFHq0S6GoHeXs0wGNE/sIOutkXgnwzrVOGCQ==}
+ zod: 3.25.76
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -10684,6 +11044,84 @@ snapshots:
'@adobe/css-tools@4.4.2': {}
+ '@ai-sdk/cerebras@1.0.35(zod@3.25.76)':
+ dependencies:
+ '@ai-sdk/openai-compatible': 1.0.31(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.14(zod@3.25.76)':
+ dependencies:
+ '@ai-sdk/provider': 3.0.5
+ '@ai-sdk/provider-utils': 4.0.10(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.25(zod@3.25.76)':
+ dependencies:
+ '@ai-sdk/provider': 3.0.5
+ '@ai-sdk/provider-utils': 4.0.10(zod@3.25.76)
+ '@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@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
+ '@standard-schema/spec': 1.1.0
+ eventsource-parser: 3.0.6
+ zod: 3.25.76
+
+ '@ai-sdk/provider-utils@4.0.10(zod@3.25.76)':
+ dependencies:
+ '@ai-sdk/provider': 3.0.5
+ '@standard-schema/spec': 1.1.0
+ 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
+
+ '@ai-sdk/provider@3.0.5':
+ dependencies:
+ json-schema: 0.4.0
+
+ '@ai-sdk/provider@3.0.6':
+ dependencies:
+ json-schema: 0.4.0
+
'@alcalzone/ansi-tokenize@0.2.3':
dependencies:
ansi-styles: 6.2.3
@@ -11683,6 +12121,11 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.8.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/wasi-threads@1.0.2':
dependencies:
tslib: 2.8.1
@@ -11915,79 +12358,101 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@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.0.4':
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
- '@img/sharp-libvips-darwin-x64@1.0.4':
+ '@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
- '@img/sharp-libvips-linux-arm64@1.0.4':
+ '@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
- '@img/sharp-libvips-linux-arm@1.0.5':
+ '@img/sharp-libvips-linux-arm@1.2.4':
optional: true
- '@img/sharp-libvips-linux-s390x@1.0.4':
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
- '@img/sharp-libvips-linux-x64@1.0.4':
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
- '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
+ '@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
- '@img/sharp-libvips-linuxmusl-x64@1.0.4':
+ '@img/sharp-libvips-linux-x64@1.2.4':
optional: true
- '@img/sharp-linux-arm64@0.33.5':
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.0.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
- '@img/sharp-linux-arm@0.33.5':
+ '@img/sharp-linux-arm@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.0.5
+ '@img/sharp-libvips-linux-arm': 1.2.4
optional: true
- '@img/sharp-linux-s390x@0.33.5':
+ '@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.0.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
- '@img/sharp-linux-x64@0.33.5':
+ '@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.0.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
- '@img/sharp-linuxmusl-arm64@0.33.5':
+ '@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.0.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
- '@img/sharp-linuxmusl-x64@0.33.5':
+ '@img/sharp-linux-x64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.0.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
optional: true
- '@img/sharp-wasm32@0.33.5':
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-wasm32@0.34.5':
dependencies:
- '@emnapi/runtime': 1.4.3
+ '@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.23)(react@19.2.3))':
@@ -12198,10 +12663,10 @@ snapshots:
dependencies:
exenv-es6: 1.1.1
- '@mistralai/mistralai@1.9.18(zod@3.25.61)':
+ '@mistralai/mistralai@1.9.18(zod@3.25.76)':
dependencies:
- zod: 3.25.61
- zod-to-json-schema: 3.24.5(zod@3.25.61)
+ zod: 3.25.76
+ zod-to-json-schema: 3.24.5(zod@3.25.76)
'@mixmark-io/domino@2.2.0': {}
@@ -12249,34 +12714,34 @@ snapshots:
'@next/env@13.5.11': {}
- '@next/env@15.2.8': {}
+ '@next/env@16.1.6': {}
'@next/eslint-plugin-next@15.3.2':
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': {}
@@ -12369,6 +12834,87 @@ snapshots:
'@open-draft/until@2.1.0': {}
+ '@openrouter/ai-sdk-provider@2.1.1(ai@6.0.57(zod@3.25.76))(zod@3.25.76)':
+ dependencies:
+ ai: 6.0.57(zod@3.25.76)
+ zod: 3.25.76
+
+ '@opentelemetry/api-logs@0.208.0':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+
+ '@opentelemetry/api@1.9.0': {}
+
+ '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/api-logs': 0.208.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
+
+ '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0)
+
+ '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/api-logs': 0.208.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
+ protobufjs: 7.5.4
+
+ '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/resources@2.5.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/api-logs': 0.208.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+
+ '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+
+ '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/semantic-conventions@1.39.0': {}
+
'@oxc-resolver/binding-darwin-arm64@11.2.0':
optional: true
@@ -12415,6 +12961,35 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
+ '@posthog/core@1.17.0':
+ dependencies:
+ cross-spawn: 7.0.6
+
+ '@posthog/types@1.336.4': {}
+
+ '@protobufjs/aspromise@1.1.2': {}
+
+ '@protobufjs/base64@1.1.2': {}
+
+ '@protobufjs/codegen@2.0.4': {}
+
+ '@protobufjs/eventemitter@1.1.0': {}
+
+ '@protobufjs/fetch@1.1.0':
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/inquire': 1.1.0
+
+ '@protobufjs/float@1.0.2': {}
+
+ '@protobufjs/inquire@1.1.0': {}
+
+ '@protobufjs/path@1.1.2': {}
+
+ '@protobufjs/pool@1.1.0': {}
+
+ '@protobufjs/utf8@1.1.0': {}
+
'@puppeteer/browsers@2.10.5':
dependencies:
debug: 4.4.1(supports-color@8.1.1)
@@ -12579,17 +13154,17 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
- '@radix-ui/react-dialog@1.1.14(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
- '@radix-ui/primitive': 1.1.2
+ '@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
- '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-focus-guards': 1.1.2(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(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.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
@@ -12607,19 +13182,6 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.23
- '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/primitive': 1.1.2
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(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.23)(react@18.3.1)
- '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.23)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.23
- '@types/react-dom': 18.3.7(@types/react@18.3.23)
-
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -12667,6 +13229,12 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.23
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.23)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.23
+
'@radix-ui/react-focus-scope@1.1.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
@@ -12735,6 +13303,28 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
+ '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(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.23))(@types/react@18.3.23)(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.23)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(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.23))(@types/react@18.3.23)(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.23)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(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.23
+ '@types/react-dom': 18.3.7(@types/react@18.3.23)
+
'@radix-ui/react-popover@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -12862,6 +13452,24 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
+ '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(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.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.23)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.23
+ '@types/react-dom': 18.3.7(@types/react@18.3.23)
+
'@radix-ui/react-roving-focus@1.1.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -12879,6 +13487,23 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(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.23))(@types/react@18.3.23)(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.23)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(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.23)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.23
+ '@types/react-dom': 18.3.7(@types/react@18.3.23)
+
'@radix-ui/react-roving-focus@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -12984,6 +13609,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.23
+ '@radix-ui/react-slot@1.2.4(@types/react@18.3.23)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.23
+
'@radix-ui/react-tabs@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -13196,6 +13828,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.40.2':
optional: true
+ '@roo-code/types@1.108.0':
+ dependencies:
+ zod: 3.25.76
+
'@sec-ant/readable-stream@0.4.1': {}
'@sevinf/maybe@0.5.0': {}
@@ -13735,9 +14371,9 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {}
- '@standard-schema/utils@0.3.0': {}
+ '@standard-schema/spec@1.1.0': {}
- '@swc/counter@0.1.3': {}
+ '@standard-schema/utils@0.3.0': {}
'@swc/helpers@0.5.15':
dependencies:
@@ -13879,11 +14515,8 @@ snapshots:
postcss: 8.5.4
tailwindcss: 4.1.8
- '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17)':
+ '@tailwindcss/typography@0.5.19(tailwindcss@3.4.17)':
dependencies:
- lodash.castarray: 4.4.0
- lodash.isplainobject: 4.0.6
- lodash.merge: 4.6.2
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.17
@@ -13896,16 +14529,16 @@ snapshots:
'@tanstack/query-core@5.76.0': {}
- '@tanstack/query-core@5.80.2': {}
+ '@tanstack/query-core@5.90.20': {}
'@tanstack/react-query@5.76.1(react@18.3.1)':
dependencies:
'@tanstack/query-core': 5.76.0
react: 18.3.1
- '@tanstack/react-query@5.80.2(react@18.3.1)':
+ '@tanstack/react-query@5.90.20(react@18.3.1)':
dependencies:
- '@tanstack/query-core': 5.80.2
+ '@tanstack/query-core': 5.90.20
react: 18.3.1
'@testing-library/dom@10.4.0':
@@ -14386,11 +15019,12 @@ 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': {}
'@vitejs/plugin-react@4.4.1(vite@6.3.6(@types/node@20.17.57)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0))':
dependencies:
@@ -14464,7 +15098,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies:
@@ -14614,6 +15248,14 @@ snapshots:
dependencies:
humanize-ms: 1.2.1
+ ai@6.0.57(zod@3.25.76):
+ dependencies:
+ '@ai-sdk/gateway': 3.0.25(zod@3.25.76)
+ '@ai-sdk/provider': 3.0.5
+ '@ai-sdk/provider-utils': 4.0.10(zod@3.25.76)
+ '@opentelemetry/api': 1.9.0
+ zod: 3.25.76
+
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -14809,14 +15451,13 @@ snapshots:
auto-bind@5.0.1: {}
- autoprefixer@10.4.21(postcss@8.5.4):
+ autoprefixer@10.4.23(postcss@8.5.6):
dependencies:
- browserslist: 4.24.5
- caniuse-lite: 1.0.30001718
- fraction.js: 4.3.7
- normalize-range: 0.1.2
+ browserslist: 4.28.1
+ caniuse-lite: 1.0.30001766
+ fraction.js: 5.3.4
picocolors: 1.1.1
- postcss: 8.5.4
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
available-typed-arrays@1.0.7:
@@ -14838,6 +15479,10 @@ snapshots:
b4a@1.6.7: {}
+ babel-plugin-react-compiler@1.0.0:
+ dependencies:
+ '@babel/types': 7.27.1
+
bail@1.0.5: {}
bail@2.0.2: {}
@@ -14873,6 +15518,8 @@ snapshots:
base64-js@1.5.1: {}
+ baseline-browser-mapping@2.9.19: {}
+
basic-ftp@5.0.5: {}
better-path-resolve@1.0.0:
@@ -14943,11 +15590,19 @@ snapshots:
browserslist@4.24.5:
dependencies:
- caniuse-lite: 1.0.30001718
+ caniuse-lite: 1.0.30001766
electron-to-chromium: 1.5.152
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.24.5)
+ browserslist@4.28.1:
+ dependencies:
+ baseline-browser-mapping: 2.9.19
+ caniuse-lite: 1.0.30001766
+ electron-to-chromium: 1.5.283
+ node-releases: 2.0.27
+ update-browserslist-db: 1.2.3(browserslist@4.28.1)
+
buffer-crc32@0.2.13: {}
buffer-crc32@1.0.0: {}
@@ -14979,10 +15634,6 @@ snapshots:
esbuild: 0.25.9
load-tsconfig: 0.2.5
- busboy@1.6.0:
- dependencies:
- streamsearch: 1.1.0
-
bytes@3.1.2: {}
c8@9.1.0:
@@ -15028,7 +15679,7 @@ snapshots:
camelize@1.0.1: {}
- caniuse-lite@1.0.30001718: {}
+ caniuse-lite@1.0.30001766: {}
ccount@2.0.1: {}
@@ -15141,7 +15792,7 @@ snapshots:
dependencies:
devtools-protocol: 0.0.1367902
mitt: 3.0.1
- zod: 3.23.8
+ zod: 3.25.76
chromium-bidi@5.1.0(devtools-protocol@0.0.1452169):
dependencies:
@@ -15252,20 +15903,8 @@ snapshots:
color-name@1.1.4: {}
- color-string@1.9.1:
- dependencies:
- color-name: 1.1.4
- simple-swizzle: 0.2.2
- optional: true
-
color-support@1.1.3: {}
- 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:
@@ -15741,6 +16380,9 @@ snapshots:
detect-libc@2.0.4: {}
+ detect-libc@2.1.2:
+ optional: true
+
detect-node-es@1.1.0: {}
detect-node@2.1.0: {}
@@ -15800,6 +16442,10 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
+ dompurify@3.3.1:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
@@ -15819,9 +16465,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- drizzle-orm@0.44.1(@libsql/client@0.15.8)(better-sqlite3@11.10.0)(gel@2.1.0)(postgres@3.4.7):
+ drizzle-orm@0.44.1(@libsql/client@0.15.8)(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(gel@2.1.0)(postgres@3.4.7):
optionalDependencies:
'@libsql/client': 0.15.8
+ '@opentelemetry/api': 1.9.0
better-sqlite3: 11.10.0
gel: 2.1.0
postgres: 3.4.7
@@ -15863,6 +16510,8 @@ snapshots:
electron-to-chromium@1.5.152: {}
+ electron-to-chromium@1.5.283: {}
+
embla-carousel-auto-scroll@8.6.0(embla-carousel@8.6.0):
dependencies:
embla-carousel: 8.6.0
@@ -15883,6 +16532,8 @@ snapshots:
embla-carousel@8.6.0: {}
+ emoji-regex-xs@2.0.1: {}
+
emoji-regex@10.4.0: {}
emoji-regex@8.0.0: {}
@@ -16296,6 +16947,8 @@ snapshots:
eventsource-parser@3.0.2: {}
+ eventsource-parser@3.0.6: {}
+
eventsource@3.0.7:
dependencies:
eventsource-parser: 3.0.2
@@ -16602,12 +17255,12 @@ snapshots:
forwarded@0.2.0: {}
- fraction.js@4.3.7: {}
+ fraction.js@5.3.4: {}
- framer-motion@12.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ framer-motion@12.29.2(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
- motion-dom: 12.16.0
- motion-utils: 12.12.1
+ motion-dom: 12.29.2
+ motion-utils: 12.29.2
tslib: 2.8.1
optionalDependencies:
'@emotion/is-prop-valid': 1.2.2
@@ -17267,9 +17920,6 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
- is-arrayish@0.3.2:
- optional: true
-
is-async-function@2.1.1:
dependencies:
async-function: 1.0.0
@@ -17598,8 +18248,12 @@ snapshots:
json-schema-traverse@0.4.1: {}
+ json-schema@0.4.0: {}
+
json-stable-stringify-without-jsonify@1.0.1: {}
+ json-stream-stringify@3.1.6: {}
+
json-stringify-safe@5.0.1: {}
json5@2.2.3: {}
@@ -17897,8 +18551,6 @@ snapshots:
lodash-es@4.17.21: {}
- lodash.castarray@4.4.0: {}
-
lodash.debounce@4.0.8: {}
lodash.defaults@4.2.0: {}
@@ -17965,6 +18617,8 @@ snapshots:
strip-ansi: 7.1.2
wrap-ansi: 9.0.0
+ long@5.3.2: {}
+
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@@ -18007,6 +18661,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.3.0: {}
@@ -18601,11 +19259,11 @@ snapshots:
fs-extra: 7.0.1
tslib: 2.8.1
- motion-dom@12.16.0:
+ motion-dom@12.29.2:
dependencies:
- motion-utils: 12.12.1
+ motion-utils: 12.29.2
- motion-utils@12.12.1: {}
+ motion-utils@12.29.2: {}
mri@1.2.0: {}
@@ -18647,40 +19305,41 @@ snapshots:
netmask@2.0.2: {}
- next-sitemap@4.2.3(next@15.2.8(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.8(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.8(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.8
- '@swc/counter': 0.1.3
+ '@next/env': 16.1.6
'@swc/helpers': 0.5.15
- busboy: 1.6.0
- caniuse-lite: 1.0.30001718
+ 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
- sharp: 0.33.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.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@@ -18726,6 +19385,8 @@ snapshots:
node-releases@2.0.19: {}
+ node-releases@2.0.27: {}
+
noms@0.0.0:
dependencies:
inherits: 2.0.4
@@ -18733,8 +19394,6 @@ snapshots:
normalize-path@3.0.0: {}
- normalize-range@0.1.2: {}
-
npm-normalize-package-bin@4.0.0: {}
npm-run-all2@8.0.3:
@@ -18852,11 +19511,6 @@ snapshots:
is-inside-container: 1.0.0
is-wsl: 3.1.0
- openai@5.12.2(ws@8.18.3)(zod@3.25.61):
- optionalDependencies:
- ws: 8.18.3
- zod: 3.25.61
-
openai@5.12.2(ws@8.18.3)(zod@3.25.76):
optionalDependencies:
ws: 8.18.3
@@ -19132,37 +19786,37 @@ snapshots:
possible-typed-array-names@1.1.0: {}
- postcss-import@15.1.0(postcss@8.5.4):
+ postcss-import@15.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.5.4
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.10
- postcss-js@4.0.1(postcss@8.5.4):
+ postcss-js@4.0.1(postcss@8.5.6):
dependencies:
camelcase-css: 2.0.1
- postcss: 8.5.4
+ postcss: 8.5.6
- postcss-load-config@4.0.2(postcss@8.5.4):
+ postcss-load-config@4.0.2(postcss@8.5.6):
dependencies:
lilconfig: 3.1.3
yaml: 2.8.0
optionalDependencies:
- postcss: 8.5.4
+ postcss: 8.5.6
- postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.4)(yaml@2.8.0):
+ postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(yaml@2.8.0):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 2.4.2
- postcss: 8.5.4
+ postcss: 8.5.6
tsx: 4.19.4
yaml: 2.8.0
- postcss-nested@6.2.0(postcss@8.5.4):
+ postcss-nested@6.2.0(postcss@8.5.6):
dependencies:
- postcss: 8.5.4
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
postcss-selector-parser@6.0.10:
@@ -19195,6 +19849,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postcss@8.5.6:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
postgres@3.4.7: {}
posthog-js@1.242.1:
@@ -19204,17 +19864,28 @@ snapshots:
preact: 10.26.6
web-vitals: 4.2.4
- posthog-js@1.249.2:
+ 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.42.0
+ dompurify: 3.3.1
fflate: 0.4.8
- preact: 10.26.6
- web-vitals: 4.2.4
+ preact: 10.28.2
+ query-selector-shadow-dom: 1.0.1
+ web-vitals: 5.1.0
posthog-node@5.1.1: {}
preact@10.26.6: {}
+ preact@10.28.2: {}
+
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.0.4
@@ -19288,6 +19959,21 @@ snapshots:
property-information@7.1.0: {}
+ protobufjs@7.5.4:
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/base64': 1.1.2
+ '@protobufjs/codegen': 2.0.4
+ '@protobufjs/eventemitter': 1.1.0
+ '@protobufjs/fetch': 1.1.0
+ '@protobufjs/float': 1.0.2
+ '@protobufjs/inquire': 1.1.0
+ '@protobufjs/path': 1.1.2
+ '@protobufjs/pool': 1.1.0
+ '@protobufjs/utf8': 1.1.0
+ '@types/node': 24.2.1
+ long: 5.3.2
+
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -19373,6 +20059,8 @@ snapshots:
quansync@0.2.11: {}
+ query-selector-shadow-dom@1.0.1: {}
+
queue-microtask@1.2.3: {}
randombytes@2.1.0:
@@ -19396,6 +20084,10 @@ snapshots:
strip-json-comments: 2.0.1
optional: true
+ react-compiler-runtime@1.0.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
react-cookie-consent@9.0.0(react@18.3.1):
dependencies:
js-cookie: 2.2.1
@@ -19919,19 +20611,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.4.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.4.1: {}
@@ -20045,31 +20737,36 @@ snapshots:
shallowequal@1.1.0: {}
- sharp@0.33.5:
+ sharp@0.34.5:
dependencies:
- color: 4.2.3
- detect-libc: 2.0.4
+ '@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:
@@ -20147,11 +20844,6 @@ snapshots:
simple-invariant@2.0.1: {}
- simple-swizzle@0.2.2:
- dependencies:
- is-arrayish: 0.3.2
- optional: true
-
sirv@3.0.1:
dependencies:
'@polka/url': 1.0.0-next.29
@@ -20287,8 +20979,6 @@ snapshots:
dependencies:
stream-chain: 2.2.5
- streamsearch@1.1.0: {}
-
streamx@2.22.0:
dependencies:
fast-fifo: 1.3.2
@@ -20502,6 +21192,8 @@ snapshots:
tailwind-merge@3.3.0: {}
+ tailwind-merge@3.4.0: {}
+
tailwindcss-animate@1.0.7(tailwindcss@3.4.17):
dependencies:
tailwindcss: 3.4.17
@@ -20526,11 +21218,11 @@ snapshots:
normalize-path: 3.0.0
object-hash: 3.0.0
picocolors: 1.1.1
- postcss: 8.5.4
- postcss-import: 15.1.0(postcss@8.5.4)
- postcss-js: 4.0.1(postcss@8.5.4)
- postcss-load-config: 4.0.2(postcss@8.5.4)
- postcss-nested: 6.2.0(postcss@8.5.4)
+ postcss: 8.5.6
+ postcss-import: 15.1.0(postcss@8.5.6)
+ postcss-js: 4.0.1(postcss@8.5.6)
+ postcss-load-config: 4.0.2(postcss@8.5.6)
+ postcss-nested: 6.2.0(postcss@8.5.6)
postcss-selector-parser: 6.1.2
resolve: 1.22.10
sucrase: 3.35.0
@@ -20699,7 +21391,7 @@ snapshots:
tslib@2.8.1: {}
- tsup@8.5.0(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0):
+ tsup@8.5.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0):
dependencies:
bundle-require: 5.1.0(esbuild@0.25.9)
cac: 6.7.14
@@ -20710,7 +21402,7 @@ snapshots:
fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1
picocolors: 1.1.1
- postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.4)(yaml@2.8.0)
+ postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(yaml@2.8.0)
resolve-from: 5.0.0
rollup: 4.40.2
source-map: 0.8.0-beta.0
@@ -20719,7 +21411,7 @@ snapshots:
tinyglobby: 0.2.14
tree-kill: 1.2.2
optionalDependencies:
- postcss: 8.5.4
+ postcss: 8.5.6
typescript: 5.8.3
transitivePeerDependencies:
- jiti
@@ -20983,6 +21675,12 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
+ update-browserslist-db@1.2.3(browserslist@4.28.1):
+ dependencies:
+ browserslist: 4.28.1
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@@ -21167,7 +21865,7 @@ snapshots:
esbuild: 0.25.9
fdir: 6.4.4(picomatch@4.0.2)
picomatch: 4.0.2
- postcss: 8.5.4
+ postcss: 8.5.6
rollup: 4.40.2
tinyglobby: 0.2.13
optionalDependencies:
@@ -21183,7 +21881,7 @@ snapshots:
esbuild: 0.25.9
fdir: 6.4.4(picomatch@4.0.2)
picomatch: 4.0.2
- postcss: 8.5.4
+ postcss: 8.5.6
rollup: 4.40.2
tinyglobby: 0.2.13
optionalDependencies:
@@ -21199,7 +21897,7 @@ snapshots:
esbuild: 0.25.9
fdir: 6.4.4(picomatch@4.0.2)
picomatch: 4.0.2
- postcss: 8.5.4
+ postcss: 8.5.6
rollup: 4.40.2
tinyglobby: 0.2.13
optionalDependencies:
@@ -21434,6 +22132,8 @@ snapshots:
web-vitals@4.2.4: {}
+ web-vitals@5.1.0: {}
+
webidl-conversions@3.0.1: {}
webidl-conversions@4.0.2: {}
@@ -21678,8 +22378,6 @@ snapshots:
yoga-layout@3.2.1: {}
- yoga-wasm-web@0.3.3: {}
-
zip-stream@4.1.1:
dependencies:
archiver-utils: 3.0.4
@@ -21692,27 +22390,19 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.7.0
- zod-to-json-schema@3.24.5(zod@3.25.61):
- dependencies:
- zod: 3.25.61
-
zod-to-json-schema@3.24.5(zod@3.25.76):
dependencies:
zod: 3.25.76
- zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.61):
+ zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.76):
dependencies:
typescript: 5.8.3
- zod: 3.25.61
+ zod: 3.25.76
zod-validation-error@3.4.1(zod@3.25.76):
dependencies:
zod: 3.25.76
- zod@3.23.8: {}
-
- zod@3.25.61: {}
-
zod@3.25.76: {}
zustand@5.0.9(@types/react@18.3.23)(react@19.2.3):
diff --git a/releases/3.42.0-release.png b/releases/3.42.0-release.png
new file mode 100644
index 0000000000..80bb7ffa35
Binary files /dev/null and b/releases/3.42.0-release.png differ
diff --git a/releases/3.43.0-release.png b/releases/3.43.0-release.png
new file mode 100644
index 0000000000..b38ad925cc
Binary files /dev/null and b/releases/3.43.0-release.png differ
diff --git a/releases/3.44.0-release.png b/releases/3.44.0-release.png
new file mode 100644
index 0000000000..ca92998b3c
Binary files /dev/null and b/releases/3.44.0-release.png differ
diff --git a/releases/3.45.0-release.png b/releases/3.45.0-release.png
new file mode 100644
index 0000000000..53e2016420
Binary files /dev/null and b/releases/3.45.0-release.png differ
diff --git a/releases/3.46.0-release.png b/releases/3.46.0-release.png
new file mode 100644
index 0000000000..10aa0cf20c
Binary files /dev/null and b/releases/3.46.0-release.png differ
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/scripts/install-vsix.js b/scripts/install-vsix.js
index 0ed9b6d376..1f58c70c32 100644
--- a/scripts/install-vsix.js
+++ b/scripts/install-vsix.js
@@ -5,6 +5,9 @@ const readline = require("readline")
// detect "yes" flags
const autoYes = process.argv.includes("-y")
+// detect nightly flag
+const isNightly = process.argv.includes("--nightly")
+
// detect editor command from args or default to "code"
const editorArg = process.argv.find((arg) => arg.startsWith("--editor="))
const defaultEditor = editorArg ? editorArg.split("=")[1] : "code"
@@ -24,14 +27,29 @@ const askQuestion = (question) => {
async function main() {
try {
- const packageJson = JSON.parse(fs.readFileSync("./src/package.json", "utf-8"))
- const name = packageJson.name
- const version = packageJson.version
- const vsixFileName = `./bin/${name}-${version}.vsix`
- const publisher = packageJson.publisher
- const extensionId = `${publisher}.${name}`
+ let name, version, publisher
- console.log("\n🚀 Roo Code VSIX Installer")
+ if (isNightly) {
+ // For nightly, read the nightly-specific package.json and get publisher from src
+ const nightlyPackageJson = JSON.parse(
+ fs.readFileSync("./apps/vscode-nightly/package.nightly.json", "utf-8"),
+ )
+ const srcPackageJson = JSON.parse(fs.readFileSync("./src/package.json", "utf-8"))
+ name = nightlyPackageJson.name
+ version = nightlyPackageJson.version
+ publisher = srcPackageJson.publisher
+ } else {
+ const packageJson = JSON.parse(fs.readFileSync("./src/package.json", "utf-8"))
+ name = packageJson.name
+ version = packageJson.version
+ publisher = packageJson.publisher
+ }
+
+ const vsixFileName = `./bin/${name}-${version}.vsix`
+ const extensionId = `${publisher}.${name}`
+ const buildType = isNightly ? "Nightly" : "Regular"
+
+ console.log(`\n🚀 Roo Code VSIX Installer (${buildType})`)
console.log("========================")
console.log("\nThis script will:")
console.log("1. Uninstall any existing version of the Roo Code extension")
diff --git a/src/__tests__/command-mentions.spec.ts b/src/__tests__/command-mentions.spec.ts
index d309045dc9..7b69d245d8 100644
--- a/src/__tests__/command-mentions.spec.ts
+++ b/src/__tests__/command-mentions.spec.ts
@@ -27,7 +27,7 @@ describe("Command Mentions", () => {
// Helper function to call parseMentions with required parameters
const callParseMentions = async (text: string) => {
- const result = await parseMentions(
+ return parseMentions(
text,
"/test/cwd", // cwd
mockUrlContentFetcher, // urlContentFetcher
@@ -36,10 +36,7 @@ describe("Command Mentions", () => {
false, // showRooIgnoredFiles
true, // includeDiagnosticMessages
50, // maxDiagnosticMessages
- undefined, // maxReadFileLine
)
- // Return just the text for backward compatibility with existing tests
- return result.text
}
describe("parseMentions with command support", () => {
@@ -56,10 +53,10 @@ describe("Command Mentions", () => {
const result = await callParseMentions(input)
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup")
- expect(result).toContain('')
- expect(result).toContain(commandContent)
- expect(result).toContain(" ")
- expect(result).toContain("Please help me set up the project")
+ expect(result.slashCommandHelp).toContain('')
+ expect(result.slashCommandHelp).toContain(commandContent)
+ expect(result.slashCommandHelp).toContain(" ")
+ expect(result.text).toContain("Please help me set up the project")
})
it("should handle multiple commands in message", async () => {
@@ -99,10 +96,10 @@ describe("Command Mentions", () => {
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup")
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "deploy")
expect(mockGetCommand).toHaveBeenCalledTimes(2) // Each unique command called once (optimized)
- expect(result).toContain('')
- expect(result).toContain("# Setup Environment")
- expect(result).toContain('')
- expect(result).toContain("# Deploy Environment")
+ expect(result.slashCommandHelp).toContain('')
+ expect(result.slashCommandHelp).toContain("# Setup Environment")
+ expect(result.slashCommandHelp).toContain('')
+ expect(result.slashCommandHelp).toContain("# Deploy Environment")
})
it("should leave non-existent commands unchanged", async () => {
@@ -114,10 +111,10 @@ describe("Command Mentions", () => {
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "nonexistent")
// The command should remain unchanged in the text
- expect(result).toBe("/nonexistent command")
+ expect(result.text).toBe("/nonexistent command")
// Should not contain any command tags
- expect(result).not.toContain('')
- expect(result).not.toContain("Command 'nonexistent' not found")
+ expect(result.slashCommandHelp).toBeUndefined()
+ expect(result.text).not.toContain("Command 'nonexistent' not found")
})
it("should handle command loading errors during existence check", async () => {
@@ -129,8 +126,8 @@ describe("Command Mentions", () => {
// When getCommand throws an error during existence check,
// the command is treated as non-existent and left unchanged
- expect(result).toBe("/error-command test")
- expect(result).not.toContain('')
+ expect(result.text).toBe("/error-command test")
+ expect(result.slashCommandHelp).toBeUndefined()
})
it("should handle command loading errors during processing", async () => {
@@ -145,9 +142,9 @@ describe("Command Mentions", () => {
const input = "/error-command test"
const result = await callParseMentions(input)
- expect(result).toContain('')
- expect(result).toContain("# Error command")
- expect(result).toContain(" ")
+ expect(result.slashCommandHelp).toContain('')
+ expect(result.slashCommandHelp).toContain("# Error command")
+ expect(result.slashCommandHelp).toContain(" ")
})
it("should handle command names with hyphens and underscores at start", async () => {
@@ -162,8 +159,8 @@ describe("Command Mentions", () => {
const result = await callParseMentions(input)
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup-dev")
- expect(result).toContain('')
- expect(result).toContain("# Dev setup")
+ expect(result.slashCommandHelp).toContain('')
+ expect(result.slashCommandHelp).toContain("# Dev setup")
})
it("should preserve command content formatting", async () => {
@@ -192,13 +189,13 @@ npm install
const input = "/complex command"
const result = await callParseMentions(input)
- expect(result).toContain('')
- expect(result).toContain("# Complex Command")
- expect(result).toContain("```bash")
- expect(result).toContain("npm install")
- expect(result).toContain("- Check file1.js")
- expect(result).toContain("> **Note**: This is important!")
- expect(result).toContain(" ")
+ expect(result.slashCommandHelp).toContain('')
+ expect(result.slashCommandHelp).toContain("# Complex Command")
+ expect(result.slashCommandHelp).toContain("```bash")
+ expect(result.slashCommandHelp).toContain("npm install")
+ expect(result.slashCommandHelp).toContain("- Check file1.js")
+ expect(result.slashCommandHelp).toContain("> **Note**: This is important!")
+ expect(result.slashCommandHelp).toContain(" ")
})
it("should handle empty command content", async () => {
@@ -212,8 +209,8 @@ npm install
const input = "/empty command"
const result = await callParseMentions(input)
- expect(result).toContain('')
- expect(result).toContain(" ")
+ expect(result.slashCommandHelp).toContain('')
+ expect(result.slashCommandHelp).toContain(" ")
// Should still include the command tags even with empty content
})
})
@@ -295,7 +292,7 @@ npm install
const input = "/setup the project"
const result = await callParseMentions(input)
- expect(result).toContain("Command 'setup' (see below for command content)")
+ expect(result.text).toContain("Command 'setup' (see below for command content)")
})
it("should leave non-existent command mentions unchanged", async () => {
@@ -304,7 +301,7 @@ npm install
const input = "/nonexistent the project"
const result = await callParseMentions(input)
- expect(result).toBe("/nonexistent the project")
+ expect(result.text).toBe("/nonexistent the project")
})
it("should process multiple commands in message", async () => {
@@ -325,8 +322,8 @@ npm install
const input = "/setup the project\nThen /deploy later"
const result = await callParseMentions(input)
- expect(result).toContain("Command 'setup' (see below for command content)")
- expect(result).toContain("Command 'deploy' (see below for command content)")
+ expect(result.text).toContain("Command 'setup' (see below for command content)")
+ expect(result.text).toContain("Command 'deploy' (see below for command content)")
})
it("should match commands anywhere with proper word boundaries", async () => {
@@ -340,22 +337,22 @@ npm install
// At the beginning - should match
let input = "/build the project"
let result = await callParseMentions(input)
- expect(result).toContain("Command 'build'")
+ expect(result.text).toContain("Command 'build'")
// After space - should match
input = "Please /build and test"
result = await callParseMentions(input)
- expect(result).toContain("Command 'build'")
+ expect(result.text).toContain("Command 'build'")
// At the end - should match
input = "Run the /build"
result = await callParseMentions(input)
- expect(result).toContain("Command 'build'")
+ expect(result.text).toContain("Command 'build'")
// At start of new line - should match
input = "Some text\n/build the project"
result = await callParseMentions(input)
- expect(result).toContain("Command 'build'")
+ expect(result.text).toContain("Command 'build'")
})
})
})
diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts
index 5b07267269..0bdbb26d46 100644
--- a/src/__tests__/extension.spec.ts
+++ b/src/__tests__/extension.spec.ts
@@ -46,6 +46,11 @@ vi.mock("@dotenvx/dotenvx", () => ({
config: vi.fn(),
}))
+// Mock fs so the extension module can safely check for optional .env.
+vi.mock("fs", () => ({
+ existsSync: vi.fn().mockReturnValue(false),
+}))
+
const mockBridgeOrchestratorDisconnect = vi.fn().mockResolvedValue(undefined)
const mockCloudServiceInstance = {
@@ -238,6 +243,36 @@ describe("extension.ts", () => {
authStateChangedHandler = undefined
})
+ test("does not call dotenvx.config when optional .env does not exist", async () => {
+ vi.resetModules()
+ vi.clearAllMocks()
+
+ const fs = await import("fs")
+ vi.mocked(fs.existsSync).mockReturnValue(false)
+
+ const dotenvx = await import("@dotenvx/dotenvx")
+
+ 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()
+
+ const fs = await import("fs")
+ vi.mocked(fs.existsSync).mockReturnValue(true)
+
+ const dotenvx = await import("@dotenvx/dotenvx")
+
+ 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/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts
index 1f95d0f6dd..f3256bd143 100644
--- a/src/__tests__/history-resume-delegation.spec.ts
+++ b/src/__tests__/history-resume-delegation.spec.ts
@@ -288,6 +288,56 @@ describe("History resume delegation - parent metadata transitions", () => {
expect((injectedMsg.content[0] as any).tool_use_id).toBe("toolu_abc123")
})
+ it("reopenParentFromDelegation injects plain text when no new_task tool_use exists in API history", async () => {
+ const provider = {
+ contextProxy: { globalStorageUri: { fsPath: "/storage" } },
+ getTaskWithId: vi.fn().mockResolvedValue({
+ historyItem: {
+ id: "p-no-tool",
+ status: "delegated",
+ awaitingChildId: "c-no-tool",
+ childIds: [],
+ ts: 100,
+ task: "Parent without tool_use",
+ tokensIn: 0,
+ tokensOut: 0,
+ totalCost: 0,
+ },
+ }),
+ emit: vi.fn(),
+ getCurrentTask: vi.fn(() => ({ taskId: "c-no-tool" })),
+ removeClineFromStack: vi.fn().mockResolvedValue(undefined),
+ createTaskWithHistoryItem: vi.fn().mockResolvedValue({
+ taskId: "p-no-tool",
+ resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
+ overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
+ overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
+ }),
+ updateTaskHistory: vi.fn().mockResolvedValue([]),
+ } as unknown as ClineProvider
+
+ // No assistant tool_use in history
+ const existingUiMessages = [{ type: "ask", ask: "tool", text: "subtask request", ts: 50 }]
+ const existingApiMessages = [{ role: "user", content: [{ type: "text", text: "Create a subtask" }], ts: 40 }]
+
+ vi.mocked(readTaskMessages).mockResolvedValue(existingUiMessages as any)
+ vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages as any)
+
+ await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
+ parentTaskId: "p-no-tool",
+ childTaskId: "c-no-tool",
+ completionResultSummary: "Subtask completed without tool_use",
+ })
+
+ const apiCall = vi.mocked(saveApiMessages).mock.calls[0][0]
+ // Should append a user text note
+ expect(apiCall.messages).toHaveLength(2)
+ const injected = apiCall.messages[1]
+ expect(injected.role).toBe("user")
+ expect((injected.content[0] as any).type).toBe("text")
+ expect((injected.content[0] as any).text).toContain("Subtask c-no-tool completed")
+ })
+
it("reopenParentFromDelegation sets skipPrevResponseIdOnce via resumeAfterDelegation", async () => {
const parentInstance: any = {
skipPrevResponseIdOnce: false,
diff --git a/src/__tests__/nested-delegation-resume.spec.ts b/src/__tests__/nested-delegation-resume.spec.ts
index 0c97ab5e2b..5dbafc949c 100644
--- a/src/__tests__/nested-delegation-resume.spec.ts
+++ b/src/__tests__/nested-delegation-resume.spec.ts
@@ -187,18 +187,21 @@ describe("Nested delegation resume (A → B → C)", () => {
type: "tool_use",
name: "attempt_completion",
params: { result: "C finished" },
+ nativeArgs: { result: "C finished" },
partial: false,
} as any
const askFinishSubTaskApproval = vi.fn(async () => true)
+ const handleError = vi.fn(async (_action: string, err: Error) => {
+ // Fail fast in this test if the tool hits an error path.
+ throw err
+ })
await attemptCompletionTool.handle(clineC, blockC, {
askApproval: vi.fn(),
- handleError: vi.fn(),
+ handleError,
pushToolResult: vi.fn(),
- removeClosingTag: vi.fn((_, v?: string) => v ?? ""),
askFinishSubTaskApproval,
- toolProtocol: "xml",
toolDescription: () => "desc",
} as any)
@@ -231,20 +234,21 @@ describe("Nested delegation resume (A → B → C)", () => {
type: "tool_use",
name: "attempt_completion",
params: { result: "B finished" },
+ nativeArgs: { result: "B finished" },
partial: false,
} as any
await attemptCompletionTool.handle(clineB, blockB, {
askApproval: vi.fn(),
- handleError: vi.fn(),
+ handleError,
pushToolResult: vi.fn(),
- removeClosingTag: vi.fn((_, v?: string) => v ?? ""),
askFinishSubTaskApproval,
- toolProtocol: "xml",
toolDescription: () => "desc",
} as any)
- // After B completes, A must be current
+ // After B completes, A should become current
+ // Note: delegation resume may fall back to a non-tool_result user message when the parent history
+ // does not contain a new_task tool_use. This should not prevent reopening the parent.
expect(currentActiveId).toBe("A")
// Ensure no resume_task asks were scheduled: verified indirectly by startTask:false on both hops
diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts
index 3284ae7629..7fac886030 100644
--- a/src/__tests__/single-open-invariant.spec.ts
+++ b/src/__tests__/single-open-invariant.spec.ts
@@ -46,10 +46,8 @@ describe("Single-open-task invariant", () => {
getState: vi.fn().mockResolvedValue({
apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 },
organizationAllowList: "*",
- diffEnabled: false,
enableCheckpoints: true,
checkpointTimeout: 60,
- fuzzyMatchThreshold: 1.0,
cloudUserInfo: null,
remoteControlEnabled: false,
}),
@@ -94,10 +92,8 @@ describe("Single-open-task invariant", () => {
},
getState: vi.fn().mockResolvedValue({
apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 },
- diffEnabled: false,
enableCheckpoints: true,
checkpointTimeout: 60,
- fuzzyMatchThreshold: 1.0,
experiments: {},
cloudUserInfo: null,
taskSyncEnabled: false,
diff --git a/src/api/index.ts b/src/api/index.ts
index 4dfe1e2ecb..30119b7dc7 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -1,7 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
-import type { ProviderSettings, ModelInfo, ToolProtocol } from "@roo-code/types"
+import type { ProviderSettings, ModelInfo } from "@roo-code/types"
import { ApiStream } from "./transform/stream"
@@ -29,7 +29,6 @@ import {
HuggingFaceHandler,
ChutesHandler,
LiteLLMHandler,
- ClaudeCodeHandler,
QwenCodeHandler,
SambaNovaHandler,
IOIntelligenceHandler,
@@ -83,16 +82,10 @@ export interface ApiHandlerCreateMessageMetadata {
* Can be "none", "auto", "required", or a specific tool choice.
*/
tool_choice?: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"]
- /**
- * The tool protocol being used (XML or Native).
- * Used by providers to determine whether to include native tool definitions.
- */
- toolProtocol?: ToolProtocol
/**
* Controls whether the model can return multiple tool calls in a single response.
- * When true, parallel tool calls are enabled (OpenAI's parallel_tool_calls=true).
- * When false (default), only one tool call is returned per response.
- * Only applies when toolProtocol is "native".
+ * When true (default), parallel tool calls are enabled (OpenAI's parallel_tool_calls=true).
+ * When false, only one tool call is returned per response.
*/
parallelToolCalls?: boolean
/**
@@ -132,8 +125,6 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
switch (apiProvider) {
case "anthropic":
return new AnthropicHandler(options)
- case "claude-code":
- return new ClaudeCodeHandler(options)
case "openrouter":
return new OpenRouterHandler(options)
case "bedrock":
diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts
index 6890e4178b..3d9798fde9 100644
--- a/src/api/providers/__tests__/anthropic-vertex.spec.ts
+++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts
@@ -162,7 +162,7 @@ describe("VertexHandler", () => {
})
expect(mockCreate).toHaveBeenCalledWith(
- {
+ expect.objectContaining({
model: "claude-3-5-sonnet-v2@20241022",
max_tokens: 8192,
temperature: 0,
@@ -191,7 +191,10 @@ describe("VertexHandler", () => {
},
],
stream: true,
- },
+ // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
+ tools: expect.any(Array),
+ tool_choice: expect.any(Object),
+ }),
undefined,
)
})
@@ -1194,19 +1197,17 @@ describe("VertexHandler", () => {
}),
}),
]),
- tool_choice: { type: "auto", disable_parallel_tool_use: true },
+ tool_choice: { type: "auto", disable_parallel_tool_use: false },
}),
undefined,
)
})
- it("should include tools even when toolProtocol is set to xml (user preference now ignored)", async () => {
- // XML protocol deprecation: user preference is now ignored when model supports native tools
+ it("should include tools when tools are provided", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
- toolProtocol: "xml",
})
const mockStream = [
@@ -1242,7 +1243,7 @@ describe("VertexHandler", () => {
// Just consume
}
- // Native is forced when supportsNativeTools===true, so tools should still be included
+ // Tool calling is request-driven: if tools are provided, we should include them.
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.arrayContaining([
diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts
index 3fa5baf81b..7a107edbc8 100644
--- a/src/api/providers/__tests__/anthropic.spec.ts
+++ b/src/api/providers/__tests__/anthropic.spec.ts
@@ -420,8 +420,7 @@ describe("AnthropicHandler", () => {
},
]
- it("should include tools in request by default (native is default)", async () => {
- // Handler uses native protocol by default via model's defaultToolProtocol
+ it("should include tools in request when tools are provided", async () => {
const stream = handler.createMessage(systemPrompt, messages, {
taskId: "test-task",
tools: mockTools,
@@ -451,11 +450,9 @@ describe("AnthropicHandler", () => {
)
})
- it("should include tools even when toolProtocol is set to xml (user preference now ignored)", async () => {
- // XML protocol deprecation: user preference is now ignored when model supports native tools
+ it("should include tools when tools are provided", async () => {
const xmlHandler = new AnthropicHandler({
...mockOptions,
- toolProtocol: "xml",
})
const stream = xmlHandler.createMessage(systemPrompt, messages, {
@@ -468,7 +465,7 @@ describe("AnthropicHandler", () => {
// Just consume
}
- // Native is forced when supportsNativeTools===true, so tools should still be included
+ // Tool calling is request-driven: if tools are provided, we should include them.
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.arrayContaining([
@@ -481,7 +478,7 @@ describe("AnthropicHandler", () => {
)
})
- it("should not include tools when no tools are provided", async () => {
+ it("should always include tools in request (tools are always present after PR #10841)", async () => {
// Handler uses native protocol by default
const stream = handler.createMessage(systemPrompt, messages, {
taskId: "test-task",
@@ -492,9 +489,11 @@ describe("AnthropicHandler", () => {
// Just consume
}
+ // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
expect(mockCreate).toHaveBeenCalledWith(
- expect.not.objectContaining({
- tools: expect.anything(),
+ expect.objectContaining({
+ tools: expect.any(Array),
+ tool_choice: expect.any(Object),
}),
expect.anything(),
)
@@ -515,7 +514,7 @@ describe("AnthropicHandler", () => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
- tool_choice: { type: "auto", disable_parallel_tool_use: true },
+ tool_choice: { type: "auto", disable_parallel_tool_use: false },
}),
expect.anything(),
)
@@ -536,13 +535,13 @@ describe("AnthropicHandler", () => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
- tool_choice: { type: "any", disable_parallel_tool_use: true },
+ tool_choice: { type: "any", disable_parallel_tool_use: false },
}),
expect.anything(),
)
})
- it("should omit both tools and tool_choice when tool_choice is 'none'", async () => {
+ it("should set tool_choice to undefined when tool_choice is 'none' (tools are still passed)", async () => {
// Handler uses native protocol by default
const stream = handler.createMessage(systemPrompt, messages, {
taskId: "test-task",
@@ -555,16 +554,13 @@ describe("AnthropicHandler", () => {
// Just consume
}
- // Verify that neither tools nor tool_choice are included in the request
+ // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
+ // When tool_choice is 'none', the converter returns undefined for tool_choice
+ // but tools are still passed since they're always present
expect(mockCreate).toHaveBeenCalledWith(
- expect.not.objectContaining({
- tools: expect.anything(),
- }),
- expect.anything(),
- )
- expect(mockCreate).toHaveBeenCalledWith(
- expect.not.objectContaining({
- tool_choice: expect.anything(),
+ expect.objectContaining({
+ tools: expect.any(Array),
+ tool_choice: undefined,
}),
expect.anything(),
)
@@ -585,7 +581,7 @@ describe("AnthropicHandler", () => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
- tool_choice: { type: "tool", name: "get_weather", disable_parallel_tool_use: true },
+ tool_choice: { type: "tool", name: "get_weather", disable_parallel_tool_use: false },
}),
expect.anything(),
)
diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
index 7d0d2548fc..6f8d121e69 100644
--- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
+++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
@@ -57,7 +57,7 @@ describe("BaseOpenAiCompatibleProvider", () => {
vi.restoreAllMocks()
})
- describe("XmlMatcher reasoning tags", () => {
+ describe("TagMatcher reasoning tags", () => {
it("should handle reasoning tags () from stream", async () => {
mockCreate.mockImplementationOnce(() => {
return {
@@ -87,7 +87,7 @@ describe("BaseOpenAiCompatibleProvider", () => {
chunks.push(chunk)
}
- // XmlMatcher yields chunks as they're processed
+ // TagMatcher yields chunks as they're processed
expect(chunks).toEqual([
{ type: "reasoning", text: "Let me think" },
{ type: "reasoning", text: " about this" },
@@ -124,7 +124,7 @@ describe("BaseOpenAiCompatibleProvider", () => {
chunks.push(chunk)
}
- // When a complete tag arrives in one chunk, XmlMatcher may not parse it
+ // When a complete tag arrives in one chunk, TagMatcher may not parse it
// This test documents the actual behavior
expect(chunks.length).toBeGreaterThan(0)
expect(chunks[0]).toEqual({ type: "text", text: "Regular text before " })
@@ -151,7 +151,7 @@ describe("BaseOpenAiCompatibleProvider", () => {
chunks.push(chunk)
}
- // XmlMatcher should handle incomplete tags and flush remaining content
+ // TagMatcher should handle incomplete tags and flush remaining content
expect(chunks.length).toBeGreaterThan(0)
expect(
chunks.some(
diff --git a/src/api/providers/__tests__/bedrock-native-tools.spec.ts b/src/api/providers/__tests__/bedrock-native-tools.spec.ts
index 0396a81744..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", () => {
@@ -242,11 +236,7 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
})
describe("createMessage with native tools", () => {
- it("should include toolConfig when tools are provided with native protocol", async () => {
- // Override model info to support native tools
- const modelInfo = handler.getModel().info
- ;(modelInfo as any).supportsNativeTools = true
-
+ it("should include toolConfig when tools are provided", async () => {
const handlerWithNativeTools = new AwsBedrockHandler({
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
awsAccessKey: "test-access-key",
@@ -254,18 +244,9 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
awsRegion: "us-east-1",
})
- // Manually set supportsNativeTools
- const getModelOriginal = handlerWithNativeTools.getModel.bind(handlerWithNativeTools)
- handlerWithNativeTools.getModel = () => {
- const model = getModelOriginal()
- model.info.supportsNativeTools = true
- return model
- }
-
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "test-task",
tools: testTools,
- toolProtocol: "native",
}
const generator = handlerWithNativeTools.createMessage(
@@ -285,7 +266,7 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
expect(commandArg.toolConfig.toolChoice).toEqual({ auto: {} })
})
- it("should not include toolConfig when toolProtocol is xml", async () => {
+ it("should always include toolConfig (tools are always present after PR #10841)", async () => {
const handlerWithNativeTools = new AwsBedrockHandler({
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
awsAccessKey: "test-access-key",
@@ -293,18 +274,9 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
awsRegion: "us-east-1",
})
- // Manually set supportsNativeTools
- const getModelOriginal = handlerWithNativeTools.getModel.bind(handlerWithNativeTools)
- handlerWithNativeTools.getModel = () => {
- const model = getModelOriginal()
- model.info.supportsNativeTools = true
- return model
- }
-
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "test-task",
- tools: testTools,
- toolProtocol: "xml", // XML protocol should not use native tools
+ // Even without explicit tools, tools are always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
}
const generator = handlerWithNativeTools.createMessage(
@@ -318,10 +290,13 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
expect(mockConverseStreamCommand).toHaveBeenCalled()
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
- expect(commandArg.toolConfig).toBeUndefined()
+ // Tools are now always present
+ expect(commandArg.toolConfig).toBeDefined()
+ expect(commandArg.toolConfig.tools).toBeDefined()
+ expect(commandArg.toolConfig.toolChoice).toEqual({ auto: {} })
})
- it("should not include toolConfig when tool_choice is none", async () => {
+ it("should include toolConfig with undefined toolChoice when tool_choice is none", async () => {
const handlerWithNativeTools = new AwsBedrockHandler({
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
awsAccessKey: "test-access-key",
@@ -329,18 +304,9 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
awsRegion: "us-east-1",
})
- // Manually set supportsNativeTools
- const getModelOriginal = handlerWithNativeTools.getModel.bind(handlerWithNativeTools)
- handlerWithNativeTools.getModel = () => {
- const model = getModelOriginal()
- model.info.supportsNativeTools = true
- return model
- }
-
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "test-task",
tools: testTools,
- toolProtocol: "native",
tool_choice: "none", // Explicitly disable tool use
}
@@ -355,7 +321,9 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
expect(mockConverseStreamCommand).toHaveBeenCalled()
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
- expect(commandArg.toolConfig).toBeUndefined()
+ // toolConfig is still provided but toolChoice is undefined for "none"
+ expect(commandArg.toolConfig).toBeDefined()
+ expect(commandArg.toolConfig.toolChoice).toBeUndefined()
})
it("should include fine-grained tool streaming beta for Claude models with native tools", async () => {
@@ -366,18 +334,9 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
awsRegion: "us-east-1",
})
- // Manually set supportsNativeTools
- const getModelOriginal = handlerWithNativeTools.getModel.bind(handlerWithNativeTools)
- handlerWithNativeTools.getModel = () => {
- const model = getModelOriginal()
- model.info.supportsNativeTools = true
- return model
- }
-
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "test-task",
tools: testTools,
- toolProtocol: "native",
}
const generator = handlerWithNativeTools.createMessage(
@@ -398,7 +357,7 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
)
})
- it("should not include fine-grained tool streaming beta when not using native tools", async () => {
+ it("should always include fine-grained tool streaming beta for Claude models", async () => {
const handlerWithNativeTools = new AwsBedrockHandler({
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
awsAccessKey: "test-access-key",
@@ -422,12 +381,11 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
expect(mockConverseStreamCommand).toHaveBeenCalled()
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
- // Should not include anthropic_beta when not using native tools
- if (commandArg.additionalModelRequestFields?.anthropic_beta) {
- expect(commandArg.additionalModelRequestFields.anthropic_beta).not.toContain(
- "fine-grained-tool-streaming-2025-05-14",
- )
- }
+ // Should always include anthropic_beta with fine-grained-tool-streaming for Claude models
+ expect(commandArg.additionalModelRequestFields).toBeDefined()
+ expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain(
+ "fine-grained-tool-streaming-2025-05-14",
+ )
})
})
diff --git a/src/api/providers/__tests__/bedrock-reasoning.spec.ts b/src/api/providers/__tests__/bedrock-reasoning.spec.ts
index abf73ff8e9..9dd271744c 100644
--- a/src/api/providers/__tests__/bedrock-reasoning.spec.ts
+++ b/src/api/providers/__tests__/bedrock-reasoning.spec.ts
@@ -221,8 +221,11 @@ describe("AwsBedrockHandler - Extended Thinking", () => {
expect(capturedPayload).toBeDefined()
expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP")
- // Verify that additionalModelRequestFields is not present or empty
- expect(capturedPayload.additionalModelRequestFields).toBeUndefined()
+ // Verify that additionalModelRequestFields contains fine-grained-tool-streaming for Claude models
+ expect(capturedPayload.additionalModelRequestFields).toBeDefined()
+ expect(capturedPayload.additionalModelRequestFields.anthropic_beta).toContain(
+ "fine-grained-tool-streaming-2025-05-14",
+ )
})
it("should enable reasoning when enableReasoningEffort is true in settings", async () => {
diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts
index d728fbb91e..115cb9fb40 100644
--- a/src/api/providers/__tests__/bedrock.spec.ts
+++ b/src/api/providers/__tests__/bedrock.spec.ts
@@ -754,14 +754,17 @@ describe("AwsBedrockHandler", () => {
expect(mockConverseStreamCommand).toHaveBeenCalled()
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
- // Should include anthropic_beta in additionalModelRequestFields
+ // Should include anthropic_beta in additionalModelRequestFields with both 1M context and fine-grained-tool-streaming
expect(commandArg.additionalModelRequestFields).toBeDefined()
- expect(commandArg.additionalModelRequestFields.anthropic_beta).toEqual(["context-1m-2025-08-07"])
+ expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain("context-1m-2025-08-07")
+ expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain(
+ "fine-grained-tool-streaming-2025-05-14",
+ )
// Should not include anthropic_version since thinking is not enabled
expect(commandArg.additionalModelRequestFields.anthropic_version).toBeUndefined()
})
- it("should not include anthropic_beta parameter when 1M context is disabled", async () => {
+ it("should not include 1M context beta when 1M context is disabled but still include fine-grained-tool-streaming", async () => {
const handler = new AwsBedrockHandler({
apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0],
awsAccessKey: "test",
@@ -784,11 +787,16 @@ describe("AwsBedrockHandler", () => {
expect(mockConverseStreamCommand).toHaveBeenCalled()
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
- // Should not include anthropic_beta in additionalModelRequestFields
- expect(commandArg.additionalModelRequestFields).toBeUndefined()
+ // Should include anthropic_beta with fine-grained-tool-streaming for Claude models
+ expect(commandArg.additionalModelRequestFields).toBeDefined()
+ expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain(
+ "fine-grained-tool-streaming-2025-05-14",
+ )
+ // Should NOT include 1M context beta
+ expect(commandArg.additionalModelRequestFields.anthropic_beta).not.toContain("context-1m-2025-08-07")
})
- it("should not include anthropic_beta parameter for non-Claude Sonnet 4 models", async () => {
+ it("should not include 1M context beta for non-Claude Sonnet 4 models but still include fine-grained-tool-streaming", async () => {
const handler = new AwsBedrockHandler({
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
awsAccessKey: "test",
@@ -811,8 +819,13 @@ describe("AwsBedrockHandler", () => {
expect(mockConverseStreamCommand).toHaveBeenCalled()
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
- // Should not include anthropic_beta for non-Sonnet 4 models
- expect(commandArg.additionalModelRequestFields).toBeUndefined()
+ // Should include anthropic_beta with fine-grained-tool-streaming for Claude models (even non-Sonnet 4)
+ expect(commandArg.additionalModelRequestFields).toBeDefined()
+ expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain(
+ "fine-grained-tool-streaming-2025-05-14",
+ )
+ // Should NOT include 1M context beta for non-Sonnet 4 models
+ expect(commandArg.additionalModelRequestFields.anthropic_beta).not.toContain("context-1m-2025-08-07")
})
it("should enable 1M context window with cross-region inference for Claude Sonnet 4", () => {
@@ -859,9 +872,12 @@ describe("AwsBedrockHandler", () => {
mockConverseStreamCommand.mock.calls.length - 1
][0] as any
- // Should include anthropic_beta in additionalModelRequestFields
+ // Should include anthropic_beta in additionalModelRequestFields with both 1M context and fine-grained-tool-streaming
expect(commandArg.additionalModelRequestFields).toBeDefined()
- expect(commandArg.additionalModelRequestFields.anthropic_beta).toEqual(["context-1m-2025-08-07"])
+ expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain("context-1m-2025-08-07")
+ expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain(
+ "fine-grained-tool-streaming-2025-05-14",
+ )
// Should not include anthropic_version since thinking is not enabled
expect(commandArg.additionalModelRequestFields.anthropic_version).toBeUndefined()
// Model ID should have cross-region prefix
diff --git a/src/api/providers/__tests__/cerebras.spec.ts b/src/api/providers/__tests__/cerebras.spec.ts
index 0915f449d0..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(),
+}))
+
+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",
+ }))
}),
}))
-// Mock DEFAULT_HEADERS
-vi.mock("../constants", () => ({
- DEFAULT_HEADERS: {
- "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",
- "X-Title": "Roo Code",
- "User-Agent": "RooCode/1.0.0",
- },
-}))
+import type { Anthropic } from "@anthropic-ai/sdk"
+
+import { cerebrasDefaultModelId, cerebrasModels, type CerebrasModelId } from "@roo-code/types"
+
+import type { ApiHandlerOptions } from "../../../shared/api"
import { CerebrasHandler } from "../cerebras"
-import { cerebrasModels, type CerebrasModelId } from "@roo-code/types"
-
-// Mock fetch globally
-global.fetch = vi.fn()
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 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)
})
- 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
- })
- })
-
- 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
-
- // 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/RooVetGit/Roo-Cline",
- "X-Title": "Roo Code",
- "User-Agent": "RooCode/1.0.0",
- }),
- }),
- )
- })
-
- it("should handle API errors properly", async () => {
- const mockErrorResponse = {
- ok: false,
- status: 400,
- text: () => Promise.resolve('{"error": {"message": "Bad Request"}}'),
- }
- 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()
- })
-
- 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 handle temperature clamping", async () => {
- const handlerWithTemp = new CerebrasHandler({
- ...mockOptions,
- modelTemperature: 2.0, // Above Cerebras max of 1.5
+ // Mock usage promise
+ const mockUsage = Promise.resolve({
+ inputTokens: 10,
+ outputTokens: 5,
})
- vi.mocked(fetch).mockResolvedValueOnce({
- ok: true,
- body: { getReader: () => ({ read: () => Promise.resolve({ done: true }), releaseLock: vi.fn() }) },
- } as any)
+ mockStreamText.mockReturnValue({
+ fullStream: mockFullStream(),
+ usage: mockUsage,
+ })
- await handlerWithTemp.createMessage("test", []).next()
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
- const requestBody = JSON.parse(vi.mocked(fetch).mock.calls[0][1]?.body as string)
- expect(requestBody.temperature).toBe(1.5) // Should be clamped
+ 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 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 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,
+ },
+ })
+
+ mockStreamText.mockReturnValue({
+ fullStream: mockFullStream(),
+ usage: mockUsage,
+ })
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ // 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.")
+
+ // 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)
+ }
+ }
+
+ const testHandler = new TestCerebrasHandler(mockOptions)
+
+ const usage = {
+ inputTokens: 100,
+ outputTokens: 50,
+ details: {
+ cachedInputTokens: 20,
+ reasoningTokens: 30,
+ },
+ }
+
+ 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 provide usage estimates when API doesn't return usage", () => {
- // Test fallback token estimation logic
+ it("should handle missing cache metrics gracefully", () => {
+ class TestCerebrasHandler extends CerebrasHandler {
+ public testProcessUsageMetrics(usage: any) {
+ return this.processUsageMetrics(usage)
+ }
+ }
+
+ 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("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"],
- },
- },
- }
-
- // MCP tool with the 'mcp--' prefix
- const mcpTool = {
- type: "function",
- function: {
- name: "mcp--server--tool",
- parameters: {
- type: "object",
- properties: {
- arg: { type: "string" },
- },
- },
- },
- }
-
- // Create a test wrapper to access protected method
+ 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()
- expect(converted![0]).toEqual(nonFunctionTool)
+ // 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)
+ }
+
+ // 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__/claude-code-caching.spec.ts b/src/api/providers/__tests__/claude-code-caching.spec.ts
deleted file mode 100644
index a0996ab244..0000000000
--- a/src/api/providers/__tests__/claude-code-caching.spec.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-import { ClaudeCodeHandler } from "../claude-code"
-import type { ApiHandlerOptions } from "../../../shared/api"
-import type { StreamChunk } from "../../../integrations/claude-code/streaming-client"
-import type { ApiStreamUsageChunk } from "../../transform/stream"
-
-// Mock the OAuth manager
-vi.mock("../../../integrations/claude-code/oauth", () => ({
- claudeCodeOAuthManager: {
- getAccessToken: vi.fn(),
- getEmail: vi.fn(),
- loadCredentials: vi.fn(),
- saveCredentials: vi.fn(),
- clearCredentials: vi.fn(),
- isAuthenticated: vi.fn(),
- },
- generateUserId: vi.fn(() => "user_abc123_account_def456_session_ghi789"),
-}))
-
-// Mock the streaming client
-vi.mock("../../../integrations/claude-code/streaming-client", () => ({
- createStreamingMessage: vi.fn(),
-}))
-
-const { claudeCodeOAuthManager } = await import("../../../integrations/claude-code/oauth")
-const { createStreamingMessage } = await import("../../../integrations/claude-code/streaming-client")
-
-const mockGetAccessToken = vi.mocked(claudeCodeOAuthManager.getAccessToken)
-const mockCreateStreamingMessage = vi.mocked(createStreamingMessage)
-
-describe("ClaudeCodeHandler - Caching Support", () => {
- let handler: ClaudeCodeHandler
- const mockOptions: ApiHandlerOptions = {
- apiModelId: "claude-sonnet-4-5",
- }
-
- beforeEach(() => {
- handler = new ClaudeCodeHandler(mockOptions)
- vi.clearAllMocks()
- mockGetAccessToken.mockResolvedValue("test-access-token")
- })
-
- it("should collect cache read tokens from API response", async () => {
- const mockStream = async function* (): AsyncGenerator {
- yield { type: "text", text: "Hello!" }
- yield {
- type: "usage",
- inputTokens: 100,
- outputTokens: 50,
- cacheReadTokens: 80,
- cacheWriteTokens: 20,
- }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockStream())
-
- const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
-
- const chunks = []
- for await (const chunk of stream) {
- chunks.push(chunk)
- }
-
- // Find the usage chunk
- const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
- expect(usageChunk).toBeDefined()
- expect(usageChunk!.inputTokens).toBe(100)
- expect(usageChunk!.outputTokens).toBe(50)
- expect(usageChunk!.cacheReadTokens).toBe(80)
- expect(usageChunk!.cacheWriteTokens).toBe(20)
- })
-
- it("should accumulate cache tokens across multiple messages", async () => {
- // Note: The streaming client handles accumulation internally.
- // Each usage chunk represents the accumulated totals for that point in the stream.
- // This test verifies that we correctly pass through the accumulated values.
- const mockStream = async function* (): AsyncGenerator {
- yield { type: "text", text: "Part 1" }
- yield {
- type: "usage",
- inputTokens: 50,
- outputTokens: 25,
- cacheReadTokens: 40,
- cacheWriteTokens: 10,
- }
- yield { type: "text", text: "Part 2" }
- yield {
- type: "usage",
- inputTokens: 100, // Accumulated: 50 + 50
- outputTokens: 50, // Accumulated: 25 + 25
- cacheReadTokens: 70, // Accumulated: 40 + 30
- cacheWriteTokens: 30, // Accumulated: 10 + 20
- }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockStream())
-
- const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
-
- const chunks = []
- for await (const chunk of stream) {
- chunks.push(chunk)
- }
-
- // Get the last usage chunk which should have accumulated totals
- const usageChunks = chunks.filter((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk[]
- expect(usageChunks.length).toBe(2)
-
- const lastUsageChunk = usageChunks[usageChunks.length - 1]
- expect(lastUsageChunk.inputTokens).toBe(100) // 50 + 50
- expect(lastUsageChunk.outputTokens).toBe(50) // 25 + 25
- expect(lastUsageChunk.cacheReadTokens).toBe(70) // 40 + 30
- expect(lastUsageChunk.cacheWriteTokens).toBe(30) // 10 + 20
- })
-
- it("should handle missing cache token fields gracefully", async () => {
- const mockStream = async function* (): AsyncGenerator {
- yield { type: "text", text: "Hello!" }
- yield {
- type: "usage",
- inputTokens: 100,
- outputTokens: 50,
- // No cache tokens provided
- }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockStream())
-
- const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
-
- const chunks = []
- for await (const chunk of stream) {
- chunks.push(chunk)
- }
-
- const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
- expect(usageChunk).toBeDefined()
- expect(usageChunk!.inputTokens).toBe(100)
- expect(usageChunk!.outputTokens).toBe(50)
- expect(usageChunk!.cacheReadTokens).toBeUndefined()
- expect(usageChunk!.cacheWriteTokens).toBeUndefined()
- })
-
- it("should report zero cost for subscription usage", async () => {
- // Claude Code is always subscription-based, cost should always be 0
- const mockStream = async function* (): AsyncGenerator {
- yield { type: "text", text: "Hello!" }
- yield {
- type: "usage",
- inputTokens: 100,
- outputTokens: 50,
- cacheReadTokens: 80,
- cacheWriteTokens: 20,
- }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockStream())
-
- const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
-
- const chunks = []
- for await (const chunk of stream) {
- chunks.push(chunk)
- }
-
- const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
- expect(usageChunk).toBeDefined()
- expect(usageChunk!.totalCost).toBe(0) // Should always be 0 for Claude Code (subscription-based)
- })
-})
diff --git a/src/api/providers/__tests__/claude-code.spec.ts b/src/api/providers/__tests__/claude-code.spec.ts
deleted file mode 100644
index 5b5bdca65a..0000000000
--- a/src/api/providers/__tests__/claude-code.spec.ts
+++ /dev/null
@@ -1,597 +0,0 @@
-import { ClaudeCodeHandler } from "../claude-code"
-import { ApiHandlerOptions } from "../../../shared/api"
-import type { StreamChunk } from "../../../integrations/claude-code/streaming-client"
-
-// Mock the OAuth manager
-vi.mock("../../../integrations/claude-code/oauth", () => ({
- claudeCodeOAuthManager: {
- getAccessToken: vi.fn(),
- getEmail: vi.fn(),
- loadCredentials: vi.fn(),
- saveCredentials: vi.fn(),
- clearCredentials: vi.fn(),
- isAuthenticated: vi.fn(),
- },
- generateUserId: vi.fn(() => "user_abc123_account_def456_session_ghi789"),
-}))
-
-// Mock the streaming client
-vi.mock("../../../integrations/claude-code/streaming-client", () => ({
- createStreamingMessage: vi.fn(),
-}))
-
-const { claudeCodeOAuthManager } = await import("../../../integrations/claude-code/oauth")
-const { createStreamingMessage } = await import("../../../integrations/claude-code/streaming-client")
-
-const mockGetAccessToken = vi.mocked(claudeCodeOAuthManager.getAccessToken)
-const mockGetEmail = vi.mocked(claudeCodeOAuthManager.getEmail)
-const mockCreateStreamingMessage = vi.mocked(createStreamingMessage)
-
-describe("ClaudeCodeHandler", () => {
- let handler: ClaudeCodeHandler
-
- beforeEach(() => {
- vi.clearAllMocks()
- const options: ApiHandlerOptions = {
- apiModelId: "claude-sonnet-4-5",
- }
- handler = new ClaudeCodeHandler(options)
- })
-
- test("should create handler with correct model configuration", () => {
- const model = handler.getModel()
- expect(model.id).toBe("claude-sonnet-4-5")
- expect(model.info.supportsImages).toBe(true)
- expect(model.info.supportsPromptCache).toBe(true)
- })
-
- test("should use default model when invalid model provided", () => {
- const options: ApiHandlerOptions = {
- apiModelId: "invalid-model",
- }
- const handlerWithInvalidModel = new ClaudeCodeHandler(options)
- const model = handlerWithInvalidModel.getModel()
-
- expect(model.id).toBe("claude-sonnet-4-5") // default model
- })
-
- test("should return model maxTokens from model definition", () => {
- const options: ApiHandlerOptions = {
- apiModelId: "claude-opus-4-5",
- }
- const handlerWithModel = new ClaudeCodeHandler(options)
- const model = handlerWithModel.getModel()
-
- expect(model.id).toBe("claude-opus-4-5")
- // Model maxTokens is 32768 as defined in claudeCodeModels for opus
- expect(model.info.maxTokens).toBe(32768)
- })
-
- test("should support reasoning effort configuration", () => {
- const options: ApiHandlerOptions = {
- apiModelId: "claude-sonnet-4-5",
- }
- const handler = new ClaudeCodeHandler(options)
- const model = handler.getModel()
-
- // Default model has supportsReasoningEffort
- expect(model.info.supportsReasoningEffort).toEqual(["disable", "low", "medium", "high"])
- expect(model.info.reasoningEffort).toBe("medium")
- })
-
- test("should throw error when not authenticated", async () => {
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- mockGetAccessToken.mockResolvedValue(null)
-
- const stream = handler.createMessage(systemPrompt, messages)
- const iterator = stream[Symbol.asyncIterator]()
-
- await expect(iterator.next()).rejects.toThrow(/not authenticated/i)
- })
-
- test("should call createStreamingMessage with thinking enabled by default", async () => {
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- mockGetAccessToken.mockResolvedValue("test-access-token")
-
- // Mock empty async generator
- const mockGenerator = async function* (): AsyncGenerator {
- // Empty generator for basic test
- }
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const stream = handler.createMessage(systemPrompt, messages)
-
- // Need to start iterating to trigger the call
- const iterator = stream[Symbol.asyncIterator]()
- await iterator.next()
-
- // Verify createStreamingMessage was called with correct parameters
- // Default model has reasoning effort of "medium" so thinking should be enabled
- // With interleaved thinking, maxTokens comes from model definition (32768 for claude-sonnet-4-5)
- expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
- accessToken: "test-access-token",
- model: "claude-sonnet-4-5",
- systemPrompt,
- messages,
- maxTokens: 32768, // model's maxTokens from claudeCodeModels definition
- thinking: {
- type: "enabled",
- budget_tokens: 32000, // medium reasoning budget_tokens
- },
- tools: undefined,
- toolChoice: undefined,
- metadata: {
- user_id: "user_abc123_account_def456_session_ghi789",
- },
- })
- })
-
- test("should disable thinking when reasoningEffort is set to disable", async () => {
- const options: ApiHandlerOptions = {
- apiModelId: "claude-sonnet-4-5",
- reasoningEffort: "disable",
- }
- const handlerNoThinking = new ClaudeCodeHandler(options)
-
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- mockGetAccessToken.mockResolvedValue("test-access-token")
-
- // Mock empty async generator
- const mockGenerator = async function* (): AsyncGenerator {
- // Empty generator for basic test
- }
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const stream = handlerNoThinking.createMessage(systemPrompt, messages)
-
- // Need to start iterating to trigger the call
- const iterator = stream[Symbol.asyncIterator]()
- await iterator.next()
-
- // Verify createStreamingMessage was called with thinking disabled
- expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
- accessToken: "test-access-token",
- model: "claude-sonnet-4-5",
- systemPrompt,
- messages,
- maxTokens: 32768, // model maxTokens from claudeCodeModels definition
- thinking: { type: "disabled" },
- tools: undefined,
- toolChoice: undefined,
- metadata: {
- user_id: "user_abc123_account_def456_session_ghi789",
- },
- })
- })
-
- test("should use high reasoning config when reasoningEffort is high", async () => {
- const options: ApiHandlerOptions = {
- apiModelId: "claude-sonnet-4-5",
- reasoningEffort: "high",
- }
- const handlerHighThinking = new ClaudeCodeHandler(options)
-
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- mockGetAccessToken.mockResolvedValue("test-access-token")
-
- // Mock empty async generator
- const mockGenerator = async function* (): AsyncGenerator {
- // Empty generator for basic test
- }
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const stream = handlerHighThinking.createMessage(systemPrompt, messages)
-
- // Need to start iterating to trigger the call
- const iterator = stream[Symbol.asyncIterator]()
- await iterator.next()
-
- // Verify createStreamingMessage was called with high thinking config
- // With interleaved thinking, maxTokens comes from model definition (32768 for claude-sonnet-4-5)
- expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
- accessToken: "test-access-token",
- model: "claude-sonnet-4-5",
- systemPrompt,
- messages,
- maxTokens: 32768, // model's maxTokens from claudeCodeModels definition
- thinking: {
- type: "enabled",
- budget_tokens: 64000, // high reasoning budget_tokens
- },
- tools: undefined,
- toolChoice: undefined,
- metadata: {
- user_id: "user_abc123_account_def456_session_ghi789",
- },
- })
- })
-
- test("should handle text content from streaming", async () => {
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- mockGetAccessToken.mockResolvedValue("test-access-token")
-
- // Mock async generator that yields text chunks
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "text", text: "Hello " }
- yield { type: "text", text: "there!" }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const stream = handler.createMessage(systemPrompt, messages)
- const results = []
-
- for await (const chunk of stream) {
- results.push(chunk)
- }
-
- expect(results).toHaveLength(2)
- expect(results[0]).toEqual({
- type: "text",
- text: "Hello ",
- })
- expect(results[1]).toEqual({
- type: "text",
- text: "there!",
- })
- })
-
- test("should handle reasoning content from streaming", async () => {
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- mockGetAccessToken.mockResolvedValue("test-access-token")
-
- // Mock async generator that yields reasoning chunks
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "reasoning", text: "I need to think about this carefully..." }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const stream = handler.createMessage(systemPrompt, messages)
- const results = []
-
- for await (const chunk of stream) {
- results.push(chunk)
- }
-
- expect(results).toHaveLength(1)
- expect(results[0]).toEqual({
- type: "reasoning",
- text: "I need to think about this carefully...",
- })
- })
-
- test("should handle mixed content types from streaming", async () => {
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- mockGetAccessToken.mockResolvedValue("test-access-token")
-
- // Mock async generator that yields mixed content
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "reasoning", text: "Let me think about this..." }
- yield { type: "text", text: "Here's my response!" }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const stream = handler.createMessage(systemPrompt, messages)
- const results = []
-
- for await (const chunk of stream) {
- results.push(chunk)
- }
-
- expect(results).toHaveLength(2)
- expect(results[0]).toEqual({
- type: "reasoning",
- text: "Let me think about this...",
- })
- expect(results[1]).toEqual({
- type: "text",
- text: "Here's my response!",
- })
- })
-
- test("should handle tool call partial chunks from streaming", async () => {
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- mockGetAccessToken.mockResolvedValue("test-access-token")
-
- // Mock async generator that yields tool call partial chunks
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "tool_call_partial", index: 0, id: "tool_123", name: "read_file", arguments: undefined }
- yield { type: "tool_call_partial", index: 0, id: undefined, name: undefined, arguments: '{"path":' }
- yield { type: "tool_call_partial", index: 0, id: undefined, name: undefined, arguments: '"test.txt"}' }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const stream = handler.createMessage(systemPrompt, messages)
- const results = []
-
- for await (const chunk of stream) {
- results.push(chunk)
- }
-
- expect(results).toHaveLength(3)
- expect(results[0]).toEqual({
- type: "tool_call_partial",
- index: 0,
- id: "tool_123",
- name: "read_file",
- arguments: undefined,
- })
- expect(results[1]).toEqual({
- type: "tool_call_partial",
- index: 0,
- id: undefined,
- name: undefined,
- arguments: '{"path":',
- })
- expect(results[2]).toEqual({
- type: "tool_call_partial",
- index: 0,
- id: undefined,
- name: undefined,
- arguments: '"test.txt"}',
- })
- })
-
- test("should handle usage and cost tracking from streaming", async () => {
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- mockGetAccessToken.mockResolvedValue("test-access-token")
-
- // Mock async generator with text and usage
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "text", text: "Hello there!" }
- yield {
- type: "usage",
- inputTokens: 10,
- outputTokens: 20,
- cacheReadTokens: 5,
- cacheWriteTokens: 3,
- }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const stream = handler.createMessage(systemPrompt, messages)
- const results = []
-
- for await (const chunk of stream) {
- results.push(chunk)
- }
-
- // Should have text chunk and usage chunk
- expect(results).toHaveLength(2)
- expect(results[0]).toEqual({
- type: "text",
- text: "Hello there!",
- })
- // Claude Code is subscription-based, no per-token cost
- expect(results[1]).toEqual({
- type: "usage",
- inputTokens: 10,
- outputTokens: 20,
- cacheReadTokens: 5,
- cacheWriteTokens: 3,
- totalCost: 0,
- })
- })
-
- test("should handle usage without cache tokens", async () => {
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- mockGetAccessToken.mockResolvedValue("test-access-token")
-
- // Mock async generator with usage without cache tokens
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "text", text: "Hello there!" }
- yield {
- type: "usage",
- inputTokens: 10,
- outputTokens: 20,
- }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const stream = handler.createMessage(systemPrompt, messages)
- const results = []
-
- for await (const chunk of stream) {
- results.push(chunk)
- }
-
- // Claude Code is subscription-based, no per-token cost
- expect(results).toHaveLength(2)
- expect(results[1]).toEqual({
- type: "usage",
- inputTokens: 10,
- outputTokens: 20,
- cacheReadTokens: undefined,
- cacheWriteTokens: undefined,
- totalCost: 0,
- })
- })
-
- test("should handle API errors from streaming", async () => {
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- mockGetAccessToken.mockResolvedValue("test-access-token")
-
- // Mock async generator that yields an error
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "error", error: "Invalid model name" }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const stream = handler.createMessage(systemPrompt, messages)
- const iterator = stream[Symbol.asyncIterator]()
-
- // Should throw an error
- await expect(iterator.next()).rejects.toThrow("Invalid model name")
- })
-
- test("should handle authentication refresh and continue streaming", async () => {
- const systemPrompt = "You are a helpful assistant"
- const messages = [{ role: "user" as const, content: "Hello" }]
-
- // First call returns a valid token
- mockGetAccessToken.mockResolvedValue("refreshed-token")
-
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "text", text: "Response after refresh" }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const stream = handler.createMessage(systemPrompt, messages)
- const results = []
-
- for await (const chunk of stream) {
- results.push(chunk)
- }
-
- expect(results).toHaveLength(1)
- expect(results[0]).toEqual({
- type: "text",
- text: "Response after refresh",
- })
-
- expect(mockCreateStreamingMessage).toHaveBeenCalledWith(
- expect.objectContaining({
- accessToken: "refreshed-token",
- }),
- )
- })
-
- describe("completePrompt", () => {
- test("should throw error when not authenticated", async () => {
- mockGetAccessToken.mockResolvedValue(null)
-
- await expect(handler.completePrompt("Test prompt")).rejects.toThrow(/not authenticated/i)
- })
-
- test("should complete prompt and return text response", async () => {
- mockGetAccessToken.mockResolvedValue("test-access-token")
- mockGetEmail.mockResolvedValue("test@example.com")
-
- // Mock async generator that yields text chunks
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "text", text: "Hello " }
- yield { type: "text", text: "world!" }
- yield { type: "usage", inputTokens: 10, outputTokens: 5 }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const result = await handler.completePrompt("Say hello")
-
- expect(result).toBe("Hello world!")
- })
-
- test("should call createStreamingMessage with empty system prompt and thinking disabled", async () => {
- mockGetAccessToken.mockResolvedValue("test-access-token")
- mockGetEmail.mockResolvedValue("test@example.com")
-
- // Mock empty async generator
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "text", text: "Response" }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- await handler.completePrompt("Test prompt")
-
- // Verify createStreamingMessage was called with correct parameters
- // System prompt is empty because the prompt text contains all context
- // createStreamingMessage will still prepend the Claude Code branding
- expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
- accessToken: "test-access-token",
- model: "claude-sonnet-4-5",
- systemPrompt: "", // Empty - branding is added by createStreamingMessage
- messages: [{ role: "user", content: "Test prompt" }],
- maxTokens: 32768,
- thinking: { type: "disabled" }, // No thinking for simple completions
- metadata: {
- user_id: "user_abc123_account_def456_session_ghi789",
- },
- })
- })
-
- test("should handle API errors from streaming", async () => {
- mockGetAccessToken.mockResolvedValue("test-access-token")
- mockGetEmail.mockResolvedValue("test@example.com")
-
- // Mock async generator that yields an error
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "error", error: "API rate limit exceeded" }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API rate limit exceeded")
- })
-
- test("should return empty string when no text chunks received", async () => {
- mockGetAccessToken.mockResolvedValue("test-access-token")
- mockGetEmail.mockResolvedValue("test@example.com")
-
- // Mock async generator that only yields usage
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "usage", inputTokens: 10, outputTokens: 0 }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- const result = await handler.completePrompt("Test prompt")
-
- expect(result).toBe("")
- })
-
- test("should use opus model maxTokens when configured", async () => {
- const options: ApiHandlerOptions = {
- apiModelId: "claude-opus-4-5",
- }
- const handlerOpus = new ClaudeCodeHandler(options)
-
- mockGetAccessToken.mockResolvedValue("test-access-token")
- mockGetEmail.mockResolvedValue("test@example.com")
-
- const mockGenerator = async function* (): AsyncGenerator {
- yield { type: "text", text: "Response" }
- }
-
- mockCreateStreamingMessage.mockReturnValue(mockGenerator())
-
- await handlerOpus.completePrompt("Test prompt")
-
- expect(mockCreateStreamingMessage).toHaveBeenCalledWith(
- expect.objectContaining({
- model: "claude-opus-4-5",
- maxTokens: 32768, // opus model maxTokens
- }),
- )
- })
- })
-})
diff --git a/src/api/providers/__tests__/deepinfra.spec.ts b/src/api/providers/__tests__/deepinfra.spec.ts
index 1df6ffee60..c4a9275762 100644
--- a/src/api/providers/__tests__/deepinfra.spec.ts
+++ b/src/api/providers/__tests__/deepinfra.spec.ts
@@ -199,7 +199,6 @@ describe("DeepInfraHandler", () => {
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
})
await messageGenerator.next()
@@ -213,9 +212,11 @@ describe("DeepInfraHandler", () => {
}),
}),
]),
- parallel_tool_calls: false,
}),
)
+ // parallel_tool_calls should be true by default when not explicitly set
+ const callArgs = mockCreate.mock.calls[0][0]
+ expect(callArgs).toHaveProperty("parallel_tool_calls", true)
})
it("should include tool_choice when provided", async () => {
@@ -232,7 +233,6 @@ describe("DeepInfraHandler", () => {
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
tool_choice: "auto",
})
await messageGenerator.next()
@@ -244,7 +244,7 @@ describe("DeepInfraHandler", () => {
)
})
- it("should not include tools when toolProtocol is xml", async () => {
+ it("should always include tools and tool_choice in request (tools are always present after PR #10841)", async () => {
mockWithResponse.mockResolvedValueOnce({
data: {
[Symbol.asyncIterator]: () => ({
@@ -257,14 +257,15 @@ describe("DeepInfraHandler", () => {
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
- tools: testTools,
- toolProtocol: "xml",
})
await messageGenerator.next()
const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0]
- expect(callArgs).not.toHaveProperty("tools")
- expect(callArgs).not.toHaveProperty("tool_choice")
+ // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
+ expect(callArgs).toHaveProperty("tools")
+ expect(callArgs).toHaveProperty("tool_choice")
+ // parallel_tool_calls should be true by default when not explicitly set
+ expect(callArgs).toHaveProperty("parallel_tool_calls", true)
})
it("should yield tool_call_partial chunks during streaming", async () => {
@@ -321,7 +322,6 @@ describe("DeepInfraHandler", () => {
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
})
const chunks = []
@@ -360,7 +360,6 @@ describe("DeepInfraHandler", () => {
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
parallelToolCalls: true,
})
await messageGenerator.next()
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 = {
- prompt_tokens: 100,
- completion_tokens: 50,
- total_tokens: 150,
- prompt_tokens_details: {
- cache_miss_tokens: 80,
- cached_tokens: 20,
+ 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 = {
+ 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",
})
+ async function* mockFullStream() {
+ yield { type: "reasoning", text: "Thinking..." }
+ yield { type: "text-delta", text: "Answer" }
+ }
+
+ const mockUsage = Promise.resolve({
+ inputTokens: 10,
+ outputTokens: 5,
+ details: {
+ reasoningTokens: 15,
+ },
+ })
+
+ const mockProviderMetadata = Promise.resolve({})
+
+ mockStreamText.mockReturnValue({
+ fullStream: mockFullStream(),
+ usage: mockUsage,
+ providerMetadata: mockProviderMetadata,
+ })
+
const stream = reasonerHandler.createMessage(systemPrompt, messages)
- for await (const _chunk of stream) {
- // Consume the stream
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
}
- // 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 usageChunks = chunks.filter((chunk) => chunk.type === "usage")
+ expect(usageChunks.length).toBe(1)
+ expect(usageChunks[0].reasoningTokens).toBe(15)
})
- it("should NOT pass thinking parameter for deepseek-chat model", async () => {
- const chatHandler = new DeepSeekHandler({
- ...mockOptions,
- apiModelId: "deepseek-chat",
- })
-
- const stream = chatHandler.createMessage(systemPrompt, messages)
- for await (const _chunk of stream) {
- // Consume the stream
- }
-
- // Verify that the thinking parameter was NOT passed to the API
- const callArgs = mockCreate.mock.calls[0][0]
- expect(callArgs.thinking).toBeUndefined()
- })
-
- 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 chunks
- const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
- expect(toolCallChunks.length).toBeGreaterThan(0)
- expect(toolCallChunks[0].name).toBe("get_weather")
+ // 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()
+
+ // 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 ac5c4396f1..77c4b10f45 100644
--- a/src/api/providers/__tests__/fireworks.spec.ts
+++ b/src/api/providers/__tests__/fireworks.spec.ts
@@ -1,595 +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,
+ }
+})
+
+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",
+ }))
+ }),
+}))
+
+import type { Anthropic } from "@anthropic-ai/sdk"
+
+import { fireworksDefaultModelId, fireworksModels, type FireworksModelId } from "@roo-code/types"
+
+import type { ApiHandlerOptions } from "../../../shared/api"
import { FireworksHandler } from "../fireworks"
-// Create mock functions
-const mockCreate = vi.fn()
-
-// Mock OpenAI module
-vi.mock("openai", () => ({
- default: vi.fn(() => ({
- chat: {
- completions: {
- create: mockCreate,
- },
- },
- })),
-}))
-
describe("FireworksHandler", () => {
let handler: FireworksHandler
+ let mockOptions: ApiHandlerOptions
beforeEach(() => {
- 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()
- })
-
- 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 the provided API key", () => {
- const fireworksApiKey = "test-fireworks-api-key"
- new FireworksHandler({ fireworksApiKey })
- expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: fireworksApiKey }))
- })
-
- it("should throw error when API key is not provided", () => {
- expect(() => new FireworksHandler({})).toThrow("API key is required")
- })
-
- 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 specified model when valid model is provided", () => {
- const testModelId: FireworksModelId = "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507"
- const handlerWithModel = new FireworksHandler({
- apiModelId: testModelId,
+ mockOptions = {
fireworksApiKey: "test-fireworks-api-key",
- })
- 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",
- })
- 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",
- })
- const model = handlerWithModel.getModel()
- expect(model.id).toBe(testModelId)
- expect(model.info).toEqual(
- expect.objectContaining({
- maxTokens: 16000,
- contextWindow: 256000,
- supportsImages: false,
- supportsPromptCache: true,
- supportsNativeTools: 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",
- })
- 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",
- })
- 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",
- })
- 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",
- })
- 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",
- })
- 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",
- })
- 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",
- })
- 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",
- })
- 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",
- })
- 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",
- })
- 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)
- })
-
- 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("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 stream = handler.createMessage("system prompt", [])
- const firstChunk = await stream.next()
-
- expect(firstChunk.done).toBe(false)
- expect(firstChunk.value).toEqual({ type: "text", text: testContent })
- })
-
- 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 stream = handler.createMessage("system prompt", [])
- const firstChunk = await stream.next()
-
- expect(firstChunk.done).toBe(false)
- expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 })
- })
-
- 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",
- })
-
- mockCreate.mockImplementationOnce(() => {
- return {
- [Symbol.asyncIterator]: () => ({
- async next() {
- return { done: true }
- },
- }),
- }
- })
-
- const systemPrompt = "Test system prompt for Fireworks"
- const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Fireworks" }]
-
- const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages)
- await messageGenerator.next()
-
- 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,
- )
- })
-
- 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",
- })
-
- mockCreate.mockImplementationOnce(() => ({
- [Symbol.asyncIterator]: () => ({
- async next() {
- return { done: true }
- },
- }),
- }))
-
- const messageGenerator = handlerWithModel.createMessage("system", [])
- await messageGenerator.next()
-
- expect(mockCreate).toHaveBeenCalledWith(
- expect.objectContaining({
- temperature: 0.5,
- }),
- undefined,
- )
- })
-
- 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",
- })
-
- mockCreate.mockImplementationOnce(() => ({
- [Symbol.asyncIterator]: () => ({
- async next() {
- return { done: true }
- },
- }),
- }))
-
- const messageGenerator = handlerWithModel.createMessage("system", [])
- await messageGenerator.next()
-
- // Model's defaultTemperature (1.0) should take precedence over provider's default (0.5)
- expect(mockCreate).toHaveBeenCalledWith(
- expect.objectContaining({
- temperature: 1.0,
- }),
- undefined,
- )
- })
-
- 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,
- })
-
- mockCreate.mockImplementationOnce(() => ({
- [Symbol.asyncIterator]: () => ({
- async next() {
- return { done: true }
- },
- }),
- }))
-
- const messageGenerator = handlerWithModel.createMessage("system", [])
- await messageGenerator.next()
-
- // User-specified temperature should take precedence over everything
- expect(mockCreate).toHaveBeenCalledWith(
- expect.objectContaining({
- temperature: 0.7,
- }),
- undefined,
- )
- })
-
- it("should handle empty response in completePrompt", async () => {
- mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: null } }] })
- const result = await handler.completePrompt("test prompt")
- expect(result).toBe("")
- })
-
- it("should handle missing choices in completePrompt", async () => {
- mockCreate.mockResolvedValueOnce({ choices: [] })
- const result = await handler.completePrompt("test prompt")
- expect(result).toBe("")
- })
-
- it("createMessage should handle stream with multiple chunks", async () => {
- mockCreate.mockImplementationOnce(async () => ({
- [Symbol.asyncIterator]: async function* () {
- yield {
- choices: [
- {
- delta: { content: "Hello" },
- index: 0,
- },
- ],
- usage: null,
- }
- yield {
- choices: [
- {
- delta: { content: " world" },
- index: 0,
- },
- ],
- usage: null,
- }
- yield {
- choices: [
- {
- delta: {},
- index: 0,
- },
- ],
- usage: {
- prompt_tokens: 5,
- completion_tokens: 10,
- total_tokens: 15,
- },
- }
- },
- }))
-
- const systemPrompt = "You are a helpful assistant."
- const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
-
- const stream = handler.createMessage(systemPrompt, messages)
- const chunks = []
- for await (const chunk of stream) {
- chunks.push(chunk)
+ apiModelId: "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507",
}
+ handler = new FireworksHandler(mockOptions)
+ vi.clearAllMocks()
+ })
- 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 })
+ describe("constructor", () => {
+ it("should initialize with provided options", () => {
+ expect(handler).toBeInstanceOf(FireworksHandler)
+ expect(handler.getModel().id).toBe(mockOptions.apiModelId)
+ })
+
+ it("should use default model ID if not provided", () => {
+ const handlerWithoutModel = new FireworksHandler({
+ ...mockOptions,
+ apiModelId: undefined,
+ })
+ expect(handlerWithoutModel.getModel().id).toBe(fireworksDefaultModelId)
+ })
+ })
+
+ 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 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 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 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,
+ }),
+ )
+ })
+
+ 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"),
+ }),
+ )
+ })
+
+ 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.",
+ }),
+ )
+ })
+
+ 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"),
+ }),
+ )
+ })
+
+ 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"),
+ }),
+ )
+ })
+
+ 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"),
+ }),
+ )
+ })
+
+ 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"),
+ }),
+ )
+ })
+
+ 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"),
+ }),
+ )
+ })
+
+ 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"),
+ }),
+ )
+ })
+
+ 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",
+ ),
+ }),
+ )
+ })
+
+ 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",
+ ),
+ }),
+ )
+ })
+
+ 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])
+ })
+
+ it("should include model parameters from getModelParams", () => {
+ const model = handler.getModel()
+ expect(model).toHaveProperty("temperature")
+ expect(model).toHaveProperty("maxTokens")
+ })
+ })
+
+ 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 Fireworks" }
+ }
+
+ 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")
+ })
+
+ it("should include usage information", async () => {
+ async function* mockFullStream() {
+ yield { type: "text-delta", text: "Test response" }
+ }
+
+ const mockUsage = Promise.resolve({
+ inputTokens: 10,
+ outputTokens: 20,
+ })
+
+ 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)
+ })
+
+ 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,
+ })
+
+ // 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)
+ })
+
+ 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,
+ }),
+ )
+ })
+
+ it("should use user-specified temperature over model and provider defaults", 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 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
+ }
+
+ // 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 })
+ })
+ })
+
+ 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",
+ }),
+ )
+ })
+
+ 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 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 providerMetadata = {
+ fireworks: {
+ 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)
+ })
+
+ 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()
+ })
+
+ it("should include reasoning tokens when provided", () => {
+ 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,
+ details: {
+ reasoningTokens: 30,
+ },
+ }
+
+ const result = testHandler.testProcessUsageMetrics(usage)
+
+ expect(result.reasoningTokens).toBe(30)
+ })
+ })
+
+ 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 () => {
+ 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)
+ 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__/gemini-handler.spec.ts b/src/api/providers/__tests__/gemini-handler.spec.ts
index 5ddd5a98a9..a9544a0b97 100644
--- a/src/api/providers/__tests__/gemini-handler.spec.ts
+++ b/src/api/providers/__tests__/gemini-handler.spec.ts
@@ -5,7 +5,10 @@ import { GeminiHandler } from "../gemini"
import type { ApiHandlerOptions } from "../../../shared/api"
describe("GeminiHandler backend support", () => {
- it("passes tools for URL context and grounding in config", async () => {
+ it("createMessage uses function declarations (URL context and grounding are only for completePrompt)", async () => {
+ // URL context and grounding are mutually exclusive with function declarations
+ // in Gemini API, so createMessage only uses function declarations.
+ // URL context/grounding are only added in completePrompt.
const options = {
apiProvider: "gemini",
enableUrlContext: true,
@@ -17,7 +20,9 @@ describe("GeminiHandler backend support", () => {
handler["client"].models.generateContentStream = stub
await handler.createMessage("instr", [] as any).next()
const config = stub.mock.calls[0][0].config
- expect(config.tools).toEqual([{ urlContext: {} }, { googleSearch: {} }])
+ // createMessage always uses function declarations only
+ // (tools are always present from ALWAYS_AVAILABLE_TOOLS)
+ expect(config.tools).toEqual([{ functionDeclarations: expect.any(Array) }])
})
it("completePrompt passes config overrides without tools when URL context and grounding disabled", async () => {
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" }))
- })
-
- 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 return default model when no model is specified", () => {
- const model = handler.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("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 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("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("constructor", () => {
+ it("should initialize with provided options", () => {
+ expect(handler).toBeInstanceOf(GroqHandler)
+ expect(handler.getModel().id).toBe(mockOptions.apiModelId)
})
- const stream = handler.createMessage("system prompt", [])
- const firstChunk = await stream.next()
-
- expect(firstChunk.done).toBe(false)
- expect(firstChunk.value).toEqual({ type: "text", text: testContent })
+ it("should use default model ID if not provided", () => {
+ const handlerWithoutModel = new GroqHandler({
+ ...mockOptions,
+ apiModelId: undefined,
+ })
+ expect(handlerWithoutModel.getModel().id).toBe(groqDefaultModelId)
+ })
})
- 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 }),
- }),
- }
+ 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])
})
- const stream = handler.createMessage("system prompt", [])
- const firstChunk = await stream.next()
-
- expect(firstChunk.done).toBe(false)
- expect(firstChunk.value).toMatchObject({
- type: "usage",
- inputTokens: 10,
- outputTokens: 20,
+ 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 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 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")
})
- // 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 }),
- }),
- }
- })
-
- const stream = handler.createMessage("system prompt", [])
- const firstChunk = await stream.next()
-
- expect(firstChunk.done).toBe(false)
- expect(firstChunk.value).toMatchObject({
- type: "usage",
- inputTokens: 100,
- outputTokens: 50,
- cacheReadTokens: 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" })
-
- mockCreate.mockImplementationOnce(() => {
- return {
- [Symbol.asyncIterator]: () => ({
- async next() {
- return { 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 systemPrompt = "Test system prompt for Groq"
- const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Groq" }]
+ it("should include usage information", async () => {
+ async function* mockFullStream() {
+ yield { type: "text-delta", text: "Test response" }
+ }
- const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages)
- await messageGenerator.next()
+ const mockUsage = Promise.resolve({
+ inputTokens: 10,
+ outputTokens: 20,
+ })
- 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 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)
+ })
+
+ 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,
+ })
+
+ // 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,
+ }),
+ )
+ })
+ })
+
+ 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)
+ })
+
+ 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(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)
+ })
+ })
+
+ 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 () => {
+ 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 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__/io-intelligence.spec.ts b/src/api/providers/__tests__/io-intelligence.spec.ts
index 78b23bd68f..99dfcefea4 100644
--- a/src/api/providers/__tests__/io-intelligence.spec.ts
+++ b/src/api/providers/__tests__/io-intelligence.spec.ts
@@ -255,7 +255,6 @@ describe("IOIntelligenceHandler", () => {
description: "Llama 4 Maverick 17B model",
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
})
})
@@ -272,7 +271,6 @@ describe("IOIntelligenceHandler", () => {
description: "Llama 4 Maverick 17B model",
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
})
})
diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts
index 64cbd6e865..9f3a641cb3 100644
--- a/src/api/providers/__tests__/lite-llm.spec.ts
+++ b/src/api/providers/__tests__/lite-llm.spec.ts
@@ -3,7 +3,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { LiteLLMHandler } from "../lite-llm"
import { ApiHandlerOptions } from "../../../shared/api"
-import { litellmDefaultModelId, litellmDefaultModelInfo, TOOL_PROTOCOL } from "@roo-code/types"
+import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types"
// Mock vscode first to avoid import errors
vi.mock("vscode", () => ({}))
@@ -41,11 +41,11 @@ vi.mock("../fetchers/modelCache", () => ({
"llama-3": { ...litellmDefaultModelInfo, maxTokens: 8192 },
"gpt-4-turbo": { ...litellmDefaultModelInfo, maxTokens: 8192 },
// Gemini models for thought signature injection tests
- "gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
- "gemini-3-flash": { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
- "gemini-2.5-pro": { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
- "google/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
- "vertex_ai/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
+ "gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 },
+ "gemini-3-flash": { ...litellmDefaultModelInfo, maxTokens: 8192 },
+ "gemini-2.5-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 },
+ "google/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 },
+ "vertex_ai/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 },
})
}),
getModelsFromCache: vi.fn().mockReturnValue(undefined),
@@ -414,6 +414,18 @@ describe("LiteLLMHandler", () => {
expect(isGeminiModel("gemini-2.5-flash")).toBe(true)
})
+ it("should detect Gemini models with spaces (LiteLLM model groups)", () => {
+ const handler = new LiteLLMHandler(mockOptions)
+ const isGeminiModel = (handler as any).isGeminiModel.bind(handler)
+
+ // LiteLLM model groups often use space-separated names with title case
+ expect(isGeminiModel("Gemini 3 Pro")).toBe(true)
+ expect(isGeminiModel("Gemini 3 Flash")).toBe(true)
+ expect(isGeminiModel("gemini 3 pro")).toBe(true)
+ expect(isGeminiModel("Gemini 2.5 Pro")).toBe(true)
+ expect(isGeminiModel("gemini 2.5 flash")).toBe(true)
+ })
+
it("should detect provider-prefixed Gemini models", () => {
const handler = new LiteLLMHandler(mockOptions)
const isGeminiModel = (handler as any).isGeminiModel.bind(handler)
@@ -421,6 +433,9 @@ describe("LiteLLMHandler", () => {
expect(isGeminiModel("google/gemini-3-pro")).toBe(true)
expect(isGeminiModel("vertex_ai/gemini-3-pro")).toBe(true)
expect(isGeminiModel("vertex/gemini-2.5-pro")).toBe(true)
+ // Space-separated variants with provider prefix
+ expect(isGeminiModel("google/gemini 3 pro")).toBe(true)
+ expect(isGeminiModel("vertex_ai/gemini 2.5 pro")).toBe(true)
})
it("should not detect non-Gemini models", () => {
@@ -568,10 +583,10 @@ describe("LiteLLMHandler", () => {
}
handler = new LiteLLMHandler(optionsWithGemini)
- // Mock fetchModel to return a Gemini model with native tool support
+ // Mock fetchModel to return a Gemini model
vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
id: "gemini-3-pro",
- info: { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
+ info: { ...litellmDefaultModelInfo, maxTokens: 8192 },
})
const systemPrompt = "You are a helpful assistant"
@@ -617,7 +632,6 @@ describe("LiteLLMHandler", () => {
function: { name: "read_file", description: "Read a file", parameters: {} },
},
],
- toolProtocol: TOOL_PROTOCOL.NATIVE,
}
const generator = handler.createMessage(systemPrompt, messages, metadata as any)
@@ -646,7 +660,7 @@ describe("LiteLLMHandler", () => {
vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
id: "gpt-4",
- info: { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
+ info: { ...litellmDefaultModelInfo, maxTokens: 8192 },
})
const systemPrompt = "You are a helpful assistant"
@@ -685,7 +699,6 @@ describe("LiteLLMHandler", () => {
function: { name: "read_file", description: "Read a file", parameters: {} },
},
],
- toolProtocol: TOOL_PROTOCOL.NATIVE,
}
const generator = handler.createMessage(systemPrompt, messages, metadata as any)
@@ -705,4 +718,206 @@ describe("LiteLLMHandler", () => {
})
})
})
+
+ describe("tool ID normalization", () => {
+ it("should truncate tool IDs longer than 64 characters", async () => {
+ const optionsWithBedrock: ApiHandlerOptions = {
+ ...mockOptions,
+ litellmModelId: "bedrock/anthropic.claude-3-sonnet",
+ }
+ handler = new LiteLLMHandler(optionsWithBedrock)
+
+ vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
+ id: "bedrock/anthropic.claude-3-sonnet",
+ info: { ...litellmDefaultModelInfo, maxTokens: 8192 },
+ })
+
+ // Create a tool ID longer than 64 characters
+ const longToolId = "toolu_" + "a".repeat(70) // 76 characters total
+
+ const systemPrompt = "You are a helpful assistant"
+ const messages: Anthropic.Messages.MessageParam[] = [
+ { role: "user", content: "Hello" },
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "I'll help you with that." },
+ { type: "tool_use", id: longToolId, name: "read_file", input: { path: "test.txt" } },
+ ],
+ },
+ {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: longToolId, content: "file contents" }],
+ },
+ ]
+
+ const mockStream = {
+ async *[Symbol.asyncIterator]() {
+ yield {
+ choices: [{ delta: { content: "Response" } }],
+ usage: { prompt_tokens: 100, completion_tokens: 20 },
+ }
+ },
+ }
+
+ mockCreate.mockReturnValue({
+ withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
+ })
+
+ const generator = handler.createMessage(systemPrompt, messages)
+ for await (const _chunk of generator) {
+ // Consume
+ }
+
+ // Verify that tool IDs are truncated to 64 characters or less
+ const createCall = mockCreate.mock.calls[0][0]
+ const assistantMessage = createCall.messages.find(
+ (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
+ )
+ const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool")
+
+ expect(assistantMessage).toBeDefined()
+ expect(assistantMessage.tool_calls[0].id.length).toBeLessThanOrEqual(64)
+
+ expect(toolMessage).toBeDefined()
+ expect(toolMessage.tool_call_id.length).toBeLessThanOrEqual(64)
+ })
+
+ it("should not modify tool IDs that are already within 64 characters", async () => {
+ const optionsWithBedrock: ApiHandlerOptions = {
+ ...mockOptions,
+ litellmModelId: "bedrock/anthropic.claude-3-sonnet",
+ }
+ handler = new LiteLLMHandler(optionsWithBedrock)
+
+ vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
+ id: "bedrock/anthropic.claude-3-sonnet",
+ info: { ...litellmDefaultModelInfo, maxTokens: 8192 },
+ })
+
+ // Create a tool ID within 64 characters
+ const shortToolId = "toolu_01ABC123" // Well under 64 characters
+
+ const systemPrompt = "You are a helpful assistant"
+ const messages: Anthropic.Messages.MessageParam[] = [
+ { role: "user", content: "Hello" },
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "I'll help you with that." },
+ { type: "tool_use", id: shortToolId, name: "read_file", input: { path: "test.txt" } },
+ ],
+ },
+ {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: shortToolId, content: "file contents" }],
+ },
+ ]
+
+ const mockStream = {
+ async *[Symbol.asyncIterator]() {
+ yield {
+ choices: [{ delta: { content: "Response" } }],
+ usage: { prompt_tokens: 100, completion_tokens: 20 },
+ }
+ },
+ }
+
+ mockCreate.mockReturnValue({
+ withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
+ })
+
+ const generator = handler.createMessage(systemPrompt, messages)
+ for await (const _chunk of generator) {
+ // Consume
+ }
+
+ // Verify that tool IDs are unchanged
+ const createCall = mockCreate.mock.calls[0][0]
+ const assistantMessage = createCall.messages.find(
+ (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
+ )
+ const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool")
+
+ expect(assistantMessage).toBeDefined()
+ expect(assistantMessage.tool_calls[0].id).toBe(shortToolId)
+
+ expect(toolMessage).toBeDefined()
+ expect(toolMessage.tool_call_id).toBe(shortToolId)
+ })
+
+ it("should maintain uniqueness with hash suffix when truncating", async () => {
+ const optionsWithBedrock: ApiHandlerOptions = {
+ ...mockOptions,
+ litellmModelId: "bedrock/anthropic.claude-3-sonnet",
+ }
+ handler = new LiteLLMHandler(optionsWithBedrock)
+
+ vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
+ id: "bedrock/anthropic.claude-3-sonnet",
+ info: { ...litellmDefaultModelInfo, maxTokens: 8192 },
+ })
+
+ // Create two tool IDs that differ only near the end
+ const longToolId1 = "toolu_" + "a".repeat(60) + "_suffix1"
+ const longToolId2 = "toolu_" + "a".repeat(60) + "_suffix2"
+
+ const systemPrompt = "You are a helpful assistant"
+ const messages: Anthropic.Messages.MessageParam[] = [
+ { role: "user", content: "Hello" },
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "I'll help." },
+ { type: "tool_use", id: longToolId1, name: "read_file", input: { path: "test1.txt" } },
+ { type: "tool_use", id: longToolId2, name: "read_file", input: { path: "test2.txt" } },
+ ],
+ },
+ {
+ role: "user",
+ content: [
+ { type: "tool_result", tool_use_id: longToolId1, content: "file1 contents" },
+ { type: "tool_result", tool_use_id: longToolId2, content: "file2 contents" },
+ ],
+ },
+ ]
+
+ const mockStream = {
+ async *[Symbol.asyncIterator]() {
+ yield {
+ choices: [{ delta: { content: "Response" } }],
+ usage: { prompt_tokens: 100, completion_tokens: 20 },
+ }
+ },
+ }
+
+ mockCreate.mockReturnValue({
+ withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
+ })
+
+ const generator = handler.createMessage(systemPrompt, messages)
+ for await (const _chunk of generator) {
+ // Consume
+ }
+
+ // Verify that truncated tool IDs are unique (hash suffix ensures this)
+ const createCall = mockCreate.mock.calls[0][0]
+ const assistantMessage = createCall.messages.find(
+ (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
+ )
+
+ expect(assistantMessage).toBeDefined()
+ expect(assistantMessage.tool_calls).toHaveLength(2)
+
+ const id1 = assistantMessage.tool_calls[0].id
+ const id2 = assistantMessage.tool_calls[1].id
+
+ // Both should be truncated to 64 characters
+ expect(id1.length).toBeLessThanOrEqual(64)
+ expect(id2.length).toBeLessThanOrEqual(64)
+
+ // They should be different (hash suffix ensures uniqueness)
+ expect(id1).not.toBe(id2)
+ })
+ })
})
diff --git a/src/api/providers/__tests__/lmstudio-native-tools.spec.ts b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts
index c2d1a92ec1..cca543a269 100644
--- a/src/api/providers/__tests__/lmstudio-native-tools.spec.ts
+++ b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts
@@ -80,9 +80,11 @@ describe("LmStudioHandler Native Tools", () => {
}),
}),
]),
- parallel_tool_calls: false,
}),
)
+ // parallel_tool_calls should be true by default when not explicitly set
+ const callArgs = mockCreate.mock.calls[0][0]
+ expect(callArgs).toHaveProperty("parallel_tool_calls", true)
})
it("should include tool_choice when provided", async () => {
@@ -108,7 +110,7 @@ describe("LmStudioHandler Native Tools", () => {
)
})
- it("should not include tools when toolProtocol is xml", async () => {
+ it("should always include tools and tool_choice in request (tools are always present after PR #10841)", async () => {
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
@@ -119,14 +121,15 @@ describe("LmStudioHandler Native Tools", () => {
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
- tools: testTools,
- toolProtocol: "xml",
})
await stream.next()
const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0]
- expect(callArgs).not.toHaveProperty("tools")
- expect(callArgs).not.toHaveProperty("tool_choice")
+ // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
+ expect(callArgs).toHaveProperty("tools")
+ expect(callArgs).toHaveProperty("tool_choice")
+ // parallel_tool_calls should be true by default when not explicitly set
+ expect(callArgs).toHaveProperty("parallel_tool_calls", true)
})
it("should yield tool_call_partial chunks during streaming", async () => {
@@ -280,7 +283,7 @@ describe("LmStudioHandler Native Tools", () => {
expect(endChunks[0].id).toBe("call_lmstudio_test")
})
- it("should work with parallel tool calls disabled", async () => {
+ it("should work with parallel tool calls disabled (sends false)", async () => {
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
@@ -296,11 +299,9 @@ describe("LmStudioHandler Native Tools", () => {
})
await stream.next()
- expect(mockCreate).toHaveBeenCalledWith(
- expect.objectContaining({
- parallel_tool_calls: false,
- }),
- )
+ // When parallelToolCalls is false, the parameter should be sent as false
+ const callArgs = mockCreate.mock.calls[0][0]
+ expect(callArgs).toHaveProperty("parallel_tool_calls", false)
})
it("should handle reasoning content alongside tool calls", async () => {
diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts
index 845481fdf7..28aae09658 100644
--- a/src/api/providers/__tests__/mistral.spec.ts
+++ b/src/api/providers/__tests__/mistral.spec.ts
@@ -119,12 +119,17 @@ describe("MistralHandler", () => {
const iterator = handler.createMessage(systemPrompt, messages)
const result = await iterator.next()
- expect(mockCreate).toHaveBeenCalledWith({
- model: mockOptions.apiModelId,
- messages: expect.any(Array),
- maxTokens: expect.any(Number),
- temperature: 0,
- })
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: mockOptions.apiModelId,
+ messages: expect.any(Array),
+ maxTokens: expect.any(Number),
+ temperature: 0,
+ // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
+ tools: expect.any(Array),
+ toolChoice: "any",
+ }),
+ )
expect(result.value).toBeDefined()
expect(result.done).toBe(false)
@@ -288,19 +293,19 @@ describe("MistralHandler", () => {
)
})
- it("should not include tools when toolProtocol is xml", async () => {
+ it("should always include tools in request (tools are always present after PR #10841)", async () => {
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "test-task",
- tools: mockTools,
- toolProtocol: "xml",
}
const iterator = handler.createMessage(systemPrompt, messages, metadata)
await iterator.next()
+ // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
expect(mockCreate).toHaveBeenCalledWith(
- expect.not.objectContaining({
- tools: expect.anything(),
+ expect.objectContaining({
+ tools: expect.any(Array),
+ toolChoice: "any",
}),
)
})
diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts
index ab919c53c2..1bfd482fd9 100644
--- a/src/api/providers/__tests__/moonshot.spec.ts
+++ b/src/api/providers/__tests__/moonshot.spec.ts
@@ -1,67 +1,28 @@
-// Mocks must come first, before imports
-const mockCreate = vi.fn()
-vi.mock("openai", () => {
- 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,
- cached_tokens: 2,
- },
- }
- }
+// 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(),
+}))
- // Return async iterator for streaming
- return {
- [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,
- cached_tokens: 2,
- },
- }
- },
- }
- }),
- },
- },
- })),
+vi.mock("ai", async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ streamText: mockStreamText,
+ generateText: mockGenerateText,
}
})
-import OpenAI from "openai"
+vi.mock("@ai-sdk/openai-compatible", () => ({
+ createOpenAICompatible: vi.fn(() => {
+ // Return a function that returns a mock language model
+ return vi.fn(() => ({
+ modelId: "moonshot-chat",
+ provider: "moonshot",
+ }))
+ }),
+}))
+
import type { Anthropic } from "@anthropic-ai/sdk"
import { moonshotDefaultModelId } from "@roo-code/types"
@@ -90,15 +51,6 @@ describe("MoonshotHandler", () => {
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
})
- it.skip("should throw error if API key is missing", () => {
- expect(() => {
- new MoonshotHandler({
- ...mockOptions,
- moonshotApiKey: undefined,
- })
- }).toThrow("Moonshot API key is required")
- })
-
it("should use default model ID if not provided", () => {
const handlerWithoutModel = new MoonshotHandler({
...mockOptions,
@@ -113,12 +65,6 @@ describe("MoonshotHandler", () => {
moonshotBaseUrl: undefined,
})
expect(handlerWithoutBaseUrl).toBeInstanceOf(MoonshotHandler)
- // The base URL is passed to OpenAI client internally
- expect(OpenAI).toHaveBeenCalledWith(
- expect.objectContaining({
- baseURL: "https://api.moonshot.ai/v1",
- }),
- )
})
it("should use chinese base URL if provided", () => {
@@ -128,18 +74,6 @@ describe("MoonshotHandler", () => {
moonshotBaseUrl: customBaseUrl,
})
expect(handlerWithCustomUrl).toBeInstanceOf(MoonshotHandler)
- // 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 MoonshotHandler(mockOptions)
- expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: mockOptions.moonshotApiKey }))
})
})
@@ -151,7 +85,7 @@ describe("MoonshotHandler", () => {
expect(model.info.maxTokens).toBe(16384)
expect(model.info.contextWindow).toBe(262144)
expect(model.info.supportsImages).toBe(false)
- expect(model.info.supportsPromptCache).toBe(true) // Should be true now
+ expect(model.info.supportsPromptCache).toBe(true)
})
it("should return provided model ID with default model info if model does not exist", () => {
@@ -162,11 +96,8 @@ describe("MoonshotHandler", () => {
const model = handlerWithInvalidModel.getModel()
expect(model.id).toBe("invalid-model") // Returns provided ID
expect(model.info).toBeDefined()
- // With the current implementation, it's the same object reference when using default model info
- expect(model.info).toBe(handler.getModel().info)
- // Should have the same base properties
+ // Should have the same base properties as default model
expect(model.info.contextWindow).toBe(handler.getModel().info.contextWindow)
- // And should have supportsPromptCache set to true
expect(model.info.supportsPromptCache).toBe(true)
})
@@ -203,6 +134,24 @@ describe("MoonshotHandler", () => {
]
it("should handle streaming responses", async () => {
+ // Mock the fullStream async generator
+ async function* mockFullStream() {
+ yield { type: "text-delta", text: "Test response" }
+ }
+
+ // Mock usage promise
+ const mockUsage = Promise.resolve({
+ inputTokens: 10,
+ outputTokens: 5,
+ details: { cachedInputTokens: undefined },
+ raw: { cached_tokens: 2 },
+ })
+
+ mockStreamText.mockReturnValue({
+ fullStream: mockFullStream(),
+ usage: mockUsage,
+ })
+
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
@@ -216,6 +165,22 @@ describe("MoonshotHandler", () => {
})
it("should include usage information", async () => {
+ async function* mockFullStream() {
+ yield { type: "text-delta", text: "Test response" }
+ }
+
+ const mockUsage = Promise.resolve({
+ inputTokens: 10,
+ outputTokens: 5,
+ details: {},
+ raw: { cached_tokens: 2 },
+ })
+
+ mockStreamText.mockReturnValue({
+ fullStream: mockFullStream(),
+ usage: mockUsage,
+ })
+
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
@@ -229,6 +194,22 @@ describe("MoonshotHandler", () => {
})
it("should include cache metrics in usage information", async () => {
+ async function* mockFullStream() {
+ yield { type: "text-delta", text: "Test response" }
+ }
+
+ const mockUsage = Promise.resolve({
+ inputTokens: 10,
+ outputTokens: 5,
+ details: {},
+ raw: { cached_tokens: 2 },
+ })
+
+ mockStreamText.mockReturnValue({
+ fullStream: mockFullStream(),
+ usage: mockUsage,
+ })
+
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
@@ -242,6 +223,23 @@ describe("MoonshotHandler", () => {
})
})
+ 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", () => {
// We need to access the protected method, so we'll create a test subclass
@@ -254,10 +252,12 @@ describe("MoonshotHandler", () => {
const testHandler = new TestMoonshotHandler(mockOptions)
const usage = {
- prompt_tokens: 100,
- completion_tokens: 50,
- total_tokens: 150,
- cached_tokens: 20,
+ inputTokens: 100,
+ outputTokens: 50,
+ details: {},
+ raw: {
+ cached_tokens: 20,
+ },
}
const result = testHandler.testProcessUsageMetrics(usage)
@@ -279,10 +279,10 @@ describe("MoonshotHandler", () => {
const testHandler = new TestMoonshotHandler(mockOptions)
const usage = {
- prompt_tokens: 100,
- completion_tokens: 50,
- total_tokens: 150,
- // No cached_tokens
+ inputTokens: 100,
+ outputTokens: 50,
+ details: {},
+ raw: {},
}
const result = testHandler.testProcessUsageMetrics(usage)
@@ -295,31 +295,25 @@ describe("MoonshotHandler", () => {
})
})
- describe("addMaxTokensIfNeeded", () => {
- it("should always add max_tokens regardless of includeMaxTokens option", () => {
- // Create a test subclass to access the protected method
+ describe("getMaxOutputTokens", () => {
+ it("should return maxTokens from model info", () => {
class TestMoonshotHandler extends MoonshotHandler {
- public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) {
- this.addMaxTokensIfNeeded(requestOptions, modelInfo)
+ public testGetMaxOutputTokens() {
+ return this.getMaxOutputTokens()
}
}
const testHandler = new TestMoonshotHandler(mockOptions)
- const requestOptions: any = {}
- const modelInfo = {
- maxTokens: 32_000,
- }
+ const result = testHandler.testGetMaxOutputTokens()
- // Test with includeMaxTokens set to false - should still add max tokens
- testHandler.testAddMaxTokensIfNeeded(requestOptions, modelInfo)
-
- expect(requestOptions.max_tokens).toBe(32_000)
+ // Default model maxTokens is 16384
+ expect(result).toBe(16384)
})
it("should use modelMaxTokens when provided", () => {
class TestMoonshotHandler extends MoonshotHandler {
- public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) {
- this.addMaxTokensIfNeeded(requestOptions, modelInfo)
+ public testGetMaxOutputTokens() {
+ return this.getMaxOutputTokens()
}
}
@@ -328,32 +322,154 @@ describe("MoonshotHandler", () => {
...mockOptions,
modelMaxTokens: customMaxTokens,
})
- const requestOptions: any = {}
- const modelInfo = {
- maxTokens: 32_000,
- }
- testHandler.testAddMaxTokensIfNeeded(requestOptions, modelInfo)
-
- expect(requestOptions.max_tokens).toBe(customMaxTokens)
+ const result = testHandler.testGetMaxOutputTokens()
+ expect(result).toBe(customMaxTokens)
})
it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => {
class TestMoonshotHandler extends MoonshotHandler {
- public testAddMaxTokensIfNeeded(requestOptions: any, modelInfo: any) {
- this.addMaxTokensIfNeeded(requestOptions, modelInfo)
+ public testGetMaxOutputTokens() {
+ return this.getMaxOutputTokens()
}
}
const testHandler = new TestMoonshotHandler(mockOptions)
- const requestOptions: any = {}
- const modelInfo = {
- maxTokens: 16_000,
+ const result = testHandler.testGetMaxOutputTokens()
+
+ // moonshot-chat 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",
+ }
}
- testHandler.testAddMaxTokensIfNeeded(requestOptions, modelInfo)
+ const mockUsage = Promise.resolve({
+ inputTokens: 10,
+ outputTokens: 5,
+ details: {},
+ raw: {},
+ })
- expect(requestOptions.max_tokens).toBe(16_000)
+ 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).
+ 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,
+ details: {},
+ raw: {},
+ })
+
+ 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)
+ }
+
+ // 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__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts
index 709c9da089..73327a3012 100644
--- a/src/api/providers/__tests__/native-ollama.spec.ts
+++ b/src/api/providers/__tests__/native-ollama.spec.ts
@@ -265,15 +265,14 @@ describe("NativeOllamaHandler", () => {
})
describe("tool calling", () => {
- it("should include tools when model supports native tools", async () => {
- // Mock model with native tool support
+ it("should include tools when tools are provided", async () => {
+ // Model metadata should not gate tool inclusion; metadata.tools controls it.
mockGetOllamaModels.mockResolvedValue({
"llama3.2": {
contextWindow: 128000,
maxTokens: 4096,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
},
})
@@ -341,15 +340,14 @@ describe("NativeOllamaHandler", () => {
)
})
- it("should not include tools when model does not support native tools", async () => {
- // Mock model without native tool support
+ it("should include tools even when model metadata doesn't advertise tool support", async () => {
+ // Model metadata should not gate tool inclusion; metadata.tools controls it.
mockGetOllamaModels.mockResolvedValue({
llama2: {
contextWindow: 4096,
maxTokens: 4096,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: false,
},
})
@@ -379,23 +377,22 @@ describe("NativeOllamaHandler", () => {
// consume stream
}
- // Verify tools were NOT passed
+ // Verify tools were passed
expect(mockChat).toHaveBeenCalledWith(
- expect.not.objectContaining({
- tools: expect.anything(),
+ expect.objectContaining({
+ tools: expect.any(Array),
}),
)
})
- it("should not include tools when toolProtocol is xml", async () => {
- // Mock model with native tool support
+ it("should not include tools when no tools are provided", async () => {
+ // Model metadata should not gate tool inclusion; metadata.tools controls it.
mockGetOllamaModels.mockResolvedValue({
"llama3.2": {
contextWindow: 128000,
maxTokens: 4096,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
},
})
@@ -412,21 +409,8 @@ describe("NativeOllamaHandler", () => {
yield { message: { content: "Response" } }
})
- const tools = [
- {
- type: "function" as const,
- function: {
- name: "get_weather",
- description: "Get the weather",
- parameters: { type: "object", properties: {} },
- },
- },
- ]
-
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }], {
taskId: "test",
- tools,
- toolProtocol: "xml",
})
// Consume the stream
@@ -434,7 +418,7 @@ describe("NativeOllamaHandler", () => {
// consume stream
}
- // Verify tools were NOT passed (XML protocol forces XML format)
+ // Verify tools were NOT passed
expect(mockChat).toHaveBeenCalledWith(
expect.not.objectContaining({
tools: expect.anything(),
@@ -443,14 +427,13 @@ describe("NativeOllamaHandler", () => {
})
it("should yield tool_call_partial when model returns tool calls", async () => {
- // Mock model with native tool support
+ // Model metadata should not gate tool inclusion; metadata.tools controls it.
mockGetOllamaModels.mockResolvedValue({
"llama3.2": {
contextWindow: 128000,
maxTokens: 4096,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
},
})
@@ -520,14 +503,13 @@ describe("NativeOllamaHandler", () => {
})
it("should yield tool_call_end events after tool_call_partial chunks", async () => {
- // Mock model with native tool support
+ // Model metadata should not gate tool inclusion; metadata.tools controls it.
mockGetOllamaModels.mockResolvedValue({
"llama3.2": {
contextWindow: 128000,
maxTokens: 4096,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
},
})
diff --git a/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts
index c7c4a48fc3..608f639ed4 100644
--- a/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts
+++ b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts
@@ -72,7 +72,6 @@ describe("OpenAiCodexHandler native tool calls", () => {
const stream = handler.createMessage("system", [{ role: "user", content: "hello" } as any], {
taskId: "t",
- toolProtocol: "native",
tools: [],
})
diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts
new file mode 100644
index 0000000000..f35d6e61ee
--- /dev/null
+++ b/src/api/providers/__tests__/openai-codex.spec.ts
@@ -0,0 +1,26 @@
+// npx vitest run api/providers/__tests__/openai-codex.spec.ts
+
+import { OpenAiCodexHandler } from "../openai-codex"
+
+describe("OpenAiCodexHandler.getModel", () => {
+ it.each(["gpt-5.1", "gpt-5", "gpt-5.1-codex", "gpt-5-codex", "gpt-5-codex-mini"])(
+ "should return specified model when a valid model id is provided: %s",
+ (apiModelId) => {
+ const handler = new OpenAiCodexHandler({ apiModelId })
+ const model = handler.getModel()
+
+ expect(model.id).toBe(apiModelId)
+ expect(model.info).toBeDefined()
+ // Default reasoning effort for GPT-5 family
+ expect(model.info.reasoningEffort).toBe("medium")
+ },
+ )
+
+ it("should fall back to default model when an invalid model id is provided", () => {
+ const handler = new OpenAiCodexHandler({ apiModelId: "not-a-real-model" })
+ const model = handler.getModel()
+
+ expect(model.id).toBe("gpt-5.2-codex")
+ expect(model.info).toBeDefined()
+ })
+})
diff --git a/src/api/providers/__tests__/openai-native-tools.spec.ts b/src/api/providers/__tests__/openai-native-tools.spec.ts
index b3c0ae0dfe..e0746f792e 100644
--- a/src/api/providers/__tests__/openai-native-tools.spec.ts
+++ b/src/api/providers/__tests__/openai-native-tools.spec.ts
@@ -5,7 +5,7 @@ import { OpenAiNativeHandler } from "../openai-native"
import type { ApiHandlerOptions } from "../../../shared/api"
describe("OpenAiHandler native tools", () => {
- it("includes tools in request when custom model info lacks supportsNativeTools (regression test)", async () => {
+ it("includes tools in request when tools are provided via metadata (regression test)", async () => {
const mockCreate = vi.fn().mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
@@ -14,10 +14,8 @@ describe("OpenAiHandler native tools", () => {
},
}))
- // Set openAiCustomModelInfo WITHOUT supportsNativeTools to simulate
- // a user-provided custom model info that doesn't specify native tool support.
- // The getModel() fix should merge NATIVE_TOOL_DEFAULTS to ensure
- // supportsNativeTools defaults to true.
+ // Set openAiCustomModelInfo without any tool capability flags; tools should
+ // still be passed whenever metadata.tools is present.
const handler = new OpenAiHandler({
openAiApiKey: "test-key",
openAiBaseUrl: "https://example.com/v1",
@@ -49,17 +47,9 @@ describe("OpenAiHandler native tools", () => {
},
]
- // Mimic the behavior in Task.attemptApiRequest() where tools are only
- // included when modelInfo.supportsNativeTools is true. This is the
- // actual regression path being tested - without the getModel() fix,
- // supportsNativeTools would be undefined and tools wouldn't be passed.
- const modelInfo = handler.getModel().info
- const supportsNativeTools = modelInfo.supportsNativeTools ?? false
-
const stream = handler.createMessage("system", [], {
taskId: "test-task-id",
- ...(supportsNativeTools && { tools }),
- ...(supportsNativeTools && { toolProtocol: "native" as const }),
+ tools,
})
await stream.next()
@@ -71,13 +61,10 @@ describe("OpenAiHandler native tools", () => {
function: expect.objectContaining({ name: "test_tool" }),
}),
]),
+ parallel_tool_calls: true,
}),
expect.anything(),
)
- // Verify parallel_tool_calls is NOT included when parallelToolCalls is not explicitly true
- // This is required for LiteLLM/Bedrock compatibility (see COM-406)
- const callArgs = mockCreate.mock.calls[0][0]
- expect(callArgs).not.toHaveProperty("parallel_tool_calls")
})
})
@@ -131,7 +118,6 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => {
const stream = handler.createMessage("system prompt", [], {
taskId: "test-task-id",
tools: mcpTools,
- toolProtocol: "native" as const,
})
// Consume the stream
@@ -199,7 +185,6 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => {
const stream = handler.createMessage("system prompt", [], {
taskId: "test-task-id",
tools: regularTools,
- toolProtocol: "native" as const,
})
// Consume the stream
@@ -281,7 +266,6 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => {
const stream = handler.createMessage("system prompt", [], {
taskId: "test-task-id",
tools: mcpToolsWithNestedObjects,
- toolProtocol: "native" as const,
})
// Consume the stream
diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts
index a95ba0a004..86bb0e9721 100644
--- a/src/api/providers/__tests__/openai-native.spec.ts
+++ b/src/api/providers/__tests__/openai-native.spec.ts
@@ -221,45 +221,6 @@ describe("OpenAiNativeHandler", () => {
expect(modelInfo.id).toBe("gpt-5.1-codex-max") // Default model
expect(modelInfo.info).toBeDefined()
})
-
- it("should have defaultToolProtocol: native for all OpenAI Native models", () => {
- // Test that all models have defaultToolProtocol: native
- const testModels = [
- "gpt-5.1-codex-max",
- "gpt-5.2",
- "gpt-5.1",
- "gpt-5",
- "gpt-5-mini",
- "gpt-5-nano",
- "gpt-4.1",
- "gpt-4.1-mini",
- "gpt-4.1-nano",
- "o3",
- "o3-high",
- "o3-low",
- "o4-mini",
- "o4-mini-high",
- "o4-mini-low",
- "o3-mini",
- "o3-mini-high",
- "o3-mini-low",
- "o1",
- "o1-preview",
- "o1-mini",
- "gpt-4o",
- "gpt-4o-mini",
- "codex-mini-latest",
- ]
-
- for (const modelId of testModels) {
- const testHandler = new OpenAiNativeHandler({
- openAiNativeApiKey: "test-api-key",
- apiModelId: modelId,
- })
- const modelInfo = testHandler.getModel()
- expect(modelInfo.info.defaultToolProtocol).toBe("native")
- }
- })
})
describe("GPT-5 models", () => {
diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts
index 4469efd4d1..73b542dbc7 100644
--- a/src/api/providers/__tests__/openai.spec.ts
+++ b/src/api/providers/__tests__/openai.spec.ts
@@ -633,11 +633,14 @@ describe("OpenAiHandler", () => {
stream: true,
stream_options: { include_usage: true },
temperature: 0,
+ tools: undefined,
+ tool_choice: undefined,
+ parallel_tool_calls: true,
},
{ path: "/models/chat/completions" },
)
- // Verify max_tokens is NOT included when includeMaxTokens is not set
+ // Verify max_tokens is NOT included when not explicitly set
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).not.toHaveProperty("max_completion_tokens")
})
@@ -679,11 +682,14 @@ describe("OpenAiHandler", () => {
{ role: "system", content: systemPrompt },
{ role: "user", content: "Hello!" },
],
+ tools: undefined,
+ tool_choice: undefined,
+ parallel_tool_calls: true,
},
{ path: "/models/chat/completions" },
)
- // Verify max_tokens is NOT included when includeMaxTokens is not set
+ // Verify max_tokens is NOT included when not explicitly set
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).not.toHaveProperty("max_completion_tokens")
})
diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts
index 8875df9a47..e03abea635 100644
--- a/src/api/providers/__tests__/openrouter.spec.ts
+++ b/src/api/providers/__tests__/openrouter.spec.ts
@@ -42,7 +42,6 @@ vitest.mock("../fetchers/modelCache", () => ({
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
@@ -66,7 +65,6 @@ vitest.mock("../fetchers/modelCache", () => ({
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 2.5,
outputPrice: 10,
description: "GPT-4o",
@@ -76,7 +74,6 @@ vitest.mock("../fetchers/modelCache", () => ({
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 15,
outputPrice: 60,
description: "OpenAI o1",
@@ -129,7 +126,6 @@ describe("OpenRouterHandler", () => {
const result = await handler.fetchModel()
expect(result.id).toBe("anthropic/claude-sonnet-4.5")
expect(result.info.supportsPromptCache).toBe(true)
- expect(result.info.supportsNativeTools).toBe(true)
})
it("honors custom maxTokens for thinking models", async () => {
diff --git a/src/api/providers/__tests__/qwen-code-native-tools.spec.ts b/src/api/providers/__tests__/qwen-code-native-tools.spec.ts
index d6766dafd6..3b470ce461 100644
--- a/src/api/providers/__tests__/qwen-code-native-tools.spec.ts
+++ b/src/api/providers/__tests__/qwen-code-native-tools.spec.ts
@@ -99,7 +99,7 @@ describe("QwenCodeHandler Native Tools", () => {
}),
}),
]),
- parallel_tool_calls: false,
+ parallel_tool_calls: true,
}),
)
})
@@ -127,7 +127,7 @@ describe("QwenCodeHandler Native Tools", () => {
)
})
- it("should not include tools when toolProtocol is xml", async () => {
+ it("should always include tools and tool_choice (tools are guaranteed to be present after ALWAYS_AVAILABLE_TOOLS)", async () => {
mockCreate.mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
@@ -138,14 +138,14 @@ describe("QwenCodeHandler Native Tools", () => {
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
- tools: testTools,
- toolProtocol: "xml",
})
await stream.next()
+ // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0]
- expect(callArgs).not.toHaveProperty("tools")
- expect(callArgs).not.toHaveProperty("tool_choice")
+ expect(callArgs).toHaveProperty("tools")
+ expect(callArgs).toHaveProperty("tool_choice")
+ expect(callArgs).toHaveProperty("parallel_tool_calls", true)
})
it("should yield tool_call_partial chunks during streaming", async () => {
diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts
index df799426a7..ea6a36b4b4 100644
--- a/src/api/providers/__tests__/requesty.spec.ts
+++ b/src/api/providers/__tests__/requesty.spec.ts
@@ -3,15 +3,12 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
-import { TOOL_PROTOCOL } from "@roo-code/types"
-
import { RequestyHandler } from "../requesty"
import { ApiHandlerOptions } from "../../../shared/api"
import { Package } from "../../../shared/package"
import { ApiHandlerCreateMessageMetadata } from "../../index"
const mockCreate = vitest.fn()
-const mockResolveToolProtocol = vitest.fn()
vitest.mock("openai", () => {
return {
@@ -27,10 +24,6 @@ vitest.mock("openai", () => {
vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) }))
-vitest.mock("../../../utils/resolveToolProtocol", () => ({
- resolveToolProtocol: (...args: any[]) => mockResolveToolProtocol(...args),
-}))
-
vitest.mock("../fetchers/modelCache", () => ({
getModels: vitest.fn().mockImplementation(() => {
return Promise.resolve({
@@ -244,9 +237,7 @@ describe("RequestyHandler", () => {
mockCreate.mockResolvedValue(mockStream)
})
- it("should include tools in request when toolProtocol is native", async () => {
- mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.NATIVE)
-
+ it("should include tools in request when tools are provided", async () => {
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "test-task",
tools: mockTools,
@@ -273,30 +264,7 @@ describe("RequestyHandler", () => {
)
})
- it("should not include tools when toolProtocol is not native", async () => {
- mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.XML)
-
- const metadata: ApiHandlerCreateMessageMetadata = {
- taskId: "test-task",
- tools: mockTools,
- tool_choice: "auto",
- }
-
- const handler = new RequestyHandler(mockOptions)
- const iterator = handler.createMessage(systemPrompt, messages, metadata)
- await iterator.next()
-
- expect(mockCreate).toHaveBeenCalledWith(
- expect.not.objectContaining({
- tools: expect.anything(),
- tool_choice: expect.anything(),
- }),
- )
- })
-
it("should handle tool_call_partial chunks in streaming response", async () => {
- mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.NATIVE)
-
const mockStreamWithToolCalls = {
async *[Symbol.asyncIterator]() {
yield {
diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts
index 2dab7c78be..a6a76fe100 100644
--- a/src/api/providers/__tests__/roo.spec.ts
+++ b/src/api/providers/__tests__/roo.spec.ts
@@ -101,27 +101,22 @@ vitest.mock("../../providers/fetchers/modelCache", () => ({
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,
- defaultToolProtocol: "native",
},
"minimax/minimax-m2:free": {
maxTokens: 32_768,
contextWindow: 1_000_000,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 0.15,
outputPrice: 0.6,
- defaultToolProtocol: "native",
},
"anthropic/claude-haiku-4.5": {
maxTokens: 8_192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 0.8,
outputPrice: 4,
- defaultToolProtocol: "native",
},
}
}
@@ -428,24 +423,12 @@ describe("RooHandler", () => {
}
})
- it("should have defaultToolProtocol: native for all roo provider models", () => {
- // Test that all models have defaultToolProtocol: native
- const testModels = ["minimax/minimax-m2:free", "anthropic/claude-haiku-4.5", "xai/grok-code-fast-1"]
- for (const modelId of testModels) {
- const handlerWithModel = new RooHandler({ apiModelId: modelId })
- const modelInfo = handlerWithModel.getModel()
- expect(modelInfo.id).toBe(modelId)
- expect((modelInfo.info as any).defaultToolProtocol).toBe("native")
- }
- })
-
it("should return cached model info with settings applied from API", () => {
const handlerWithMinimax = new RooHandler({
apiModelId: "minimax/minimax-m2:free",
})
const modelInfo = handlerWithMinimax.getModel()
// The settings from API should already be applied in the cached model info
- expect(modelInfo.info.supportsNativeTools).toBe(true)
expect(modelInfo.info.inputPrice).toBe(0.15)
expect(modelInfo.info.outputPrice).toBe(0.6)
})
diff --git a/src/api/providers/__tests__/unbound.spec.ts b/src/api/providers/__tests__/unbound.spec.ts
index f03442a704..e95586dc6b 100644
--- a/src/api/providers/__tests__/unbound.spec.ts
+++ b/src/api/providers/__tests__/unbound.spec.ts
@@ -15,7 +15,6 @@ vitest.mock("../fetchers/modelCache", () => ({
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
@@ -28,7 +27,6 @@ vitest.mock("../fetchers/modelCache", () => ({
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
@@ -41,7 +39,6 @@ vitest.mock("../fetchers/modelCache", () => ({
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
@@ -54,7 +51,6 @@ vitest.mock("../fetchers/modelCache", () => ({
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 5,
outputPrice: 15,
description: "GPT-4o",
@@ -64,7 +60,6 @@ vitest.mock("../fetchers/modelCache", () => ({
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 1,
outputPrice: 3,
description: "O3 Mini",
@@ -353,7 +348,7 @@ describe("UnboundHandler", () => {
},
]
- it("should include tools in request when model supports native tools and tools are provided", async () => {
+ it("should include tools in request when tools are provided", async () => {
mockWithResponse.mockResolvedValueOnce({
data: {
[Symbol.asyncIterator]: () => ({
@@ -367,7 +362,6 @@ describe("UnboundHandler", () => {
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
})
await messageGenerator.next()
@@ -381,7 +375,7 @@ describe("UnboundHandler", () => {
}),
}),
]),
- parallel_tool_calls: false,
+ parallel_tool_calls: true,
}),
expect.objectContaining({
headers: {
@@ -405,7 +399,6 @@ describe("UnboundHandler", () => {
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
tool_choice: "auto",
})
await messageGenerator.next()
@@ -422,7 +415,7 @@ describe("UnboundHandler", () => {
)
})
- it("should not include tools when toolProtocol is xml", async () => {
+ it("should always include tools and tool_choice (tools are guaranteed to be present after ALWAYS_AVAILABLE_TOOLS)", async () => {
mockWithResponse.mockResolvedValueOnce({
data: {
[Symbol.asyncIterator]: () => ({
@@ -435,14 +428,14 @@ describe("UnboundHandler", () => {
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
- tools: testTools,
- toolProtocol: "xml",
})
await messageGenerator.next()
+ // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0]
- expect(callArgs).not.toHaveProperty("tools")
- expect(callArgs).not.toHaveProperty("tool_choice")
+ expect(callArgs).toHaveProperty("tools")
+ expect(callArgs).toHaveProperty("tool_choice")
+ expect(callArgs).toHaveProperty("parallel_tool_calls", true)
})
it("should yield tool_call_partial chunks during streaming", async () => {
@@ -499,7 +492,6 @@ describe("UnboundHandler", () => {
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
})
const chunks = []
@@ -538,7 +530,6 @@ describe("UnboundHandler", () => {
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
parallelToolCalls: true,
})
await messageGenerator.next()
diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts
index 3c6b1c1069..9ff804e0c4 100644
--- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts
+++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts
@@ -315,7 +315,6 @@ describe("VercelAiGatewayHandler", () => {
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
})
await messageGenerator.next()
@@ -339,7 +338,6 @@ describe("VercelAiGatewayHandler", () => {
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
tool_choice: "auto",
})
await messageGenerator.next()
@@ -351,13 +349,12 @@ describe("VercelAiGatewayHandler", () => {
)
})
- it("should set parallel_tool_calls when toolProtocol is native", async () => {
+ it("should set parallel_tool_calls when parallelToolCalls is enabled", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
parallelToolCalls: true,
})
await messageGenerator.next()
@@ -369,19 +366,19 @@ describe("VercelAiGatewayHandler", () => {
)
})
- it("should default parallel_tool_calls to false", async () => {
+ it("should include parallel_tool_calls: true by default", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
const messageGenerator = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
})
await messageGenerator.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
- parallel_tool_calls: false,
+ tools: expect.any(Array),
+ parallel_tool_calls: true,
}),
)
})
@@ -445,7 +442,6 @@ describe("VercelAiGatewayHandler", () => {
const stream = handler.createMessage("test prompt", [], {
taskId: "test-task-id",
tools: testTools,
- toolProtocol: "native",
})
const chunks = []
diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts
index e277ce5330..305305d228 100644
--- a/src/api/providers/__tests__/vscode-lm.spec.ts
+++ b/src/api/providers/__tests__/vscode-lm.spec.ts
@@ -180,7 +180,7 @@ describe("VsCodeLmHandler", () => {
})
})
- it("should handle tool calls as text when not using native tool protocol", async () => {
+ it("should emit tool_call chunks when tools are provided", async () => {
const systemPrompt = "You are a helpful assistant"
const messages: Anthropic.Messages.MessageParam[] = [
{
@@ -210,7 +210,27 @@ describe("VsCodeLmHandler", () => {
})(),
})
- const stream = handler.createMessage(systemPrompt, messages)
+ const tools = [
+ {
+ type: "function" as const,
+ function: {
+ name: "calculator",
+ description: "A simple calculator",
+ parameters: {
+ type: "object",
+ properties: {
+ operation: { type: "string" },
+ numbers: { type: "array", items: { type: "number" } },
+ },
+ },
+ },
+ },
+ ]
+
+ const stream = handler.createMessage(systemPrompt, messages, {
+ taskId: "test-task",
+ tools,
+ })
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
@@ -218,12 +238,14 @@ describe("VsCodeLmHandler", () => {
expect(chunks).toHaveLength(2) // Tool call chunk + usage chunk
expect(chunks[0]).toEqual({
- type: "text",
- text: JSON.stringify({ type: "tool_call", ...toolCallData }),
+ type: "tool_call",
+ id: toolCallData.callId,
+ name: toolCallData.name,
+ arguments: JSON.stringify(toolCallData.arguments),
})
})
- it("should handle native tool calls when using native tool protocol", async () => {
+ it("should handle native tool calls when tools are provided", async () => {
const systemPrompt = "You are a helpful assistant"
const messages: Anthropic.Messages.MessageParam[] = [
{
@@ -272,7 +294,6 @@ describe("VsCodeLmHandler", () => {
const stream = handler.createMessage(systemPrompt, messages, {
taskId: "test-task",
- toolProtocol: "native",
tools,
})
const chunks = []
@@ -289,7 +310,7 @@ describe("VsCodeLmHandler", () => {
})
})
- it("should pass tools to request options when using native tool protocol", async () => {
+ it("should pass tools to request options when tools are provided", async () => {
const systemPrompt = "You are a helpful assistant"
const messages: Anthropic.Messages.MessageParam[] = [
{
@@ -327,7 +348,6 @@ describe("VsCodeLmHandler", () => {
const stream = handler.createMessage(systemPrompt, messages, {
taskId: "test-task",
- toolProtocol: "native",
tools,
})
const chunks = []
@@ -376,10 +396,11 @@ describe("VsCodeLmHandler", () => {
describe("getModel", () => {
it("should return model info when client exists", async () => {
const mockModel = { ...mockLanguageModelChat }
- ;(vscode.lm.selectChatModels as Mock).mockResolvedValueOnce([mockModel])
-
- // Initialize client
- await handler["getClient"]()
+ // The handler starts async initialization in the constructor.
+ // Make the test deterministic by explicitly (re)initializing here.
+ ;(vscode.lm.selectChatModels as Mock).mockResolvedValue([mockModel])
+ handler["client"] = null
+ await handler.initializeClient()
const model = handler.getModel()
expect(model.id).toBe("test-model")
@@ -395,24 +416,84 @@ describe("VsCodeLmHandler", () => {
expect(model.info).toBeDefined()
})
- it("should return supportsNativeTools and defaultToolProtocol in model info", async () => {
+ it("should return basic model info when client exists", async () => {
const mockModel = { ...mockLanguageModelChat }
- ;(vscode.lm.selectChatModels as Mock).mockResolvedValueOnce([mockModel])
-
- // Initialize client
- await handler["getClient"]()
+ // The handler starts async initialization in the constructor.
+ // Make the test deterministic by explicitly (re)initializing here.
+ ;(vscode.lm.selectChatModels as Mock).mockResolvedValue([mockModel])
+ handler["client"] = null
+ await handler.initializeClient()
const model = handler.getModel()
- expect(model.info.supportsNativeTools).toBe(true)
- expect(model.info.defaultToolProtocol).toBe("native")
+ expect(model.info).toBeDefined()
+ expect(model.info.contextWindow).toBe(4096)
})
- it("should return supportsNativeTools and defaultToolProtocol in fallback model info", () => {
+ it("should return fallback model info when no client exists", () => {
// Clear the client first
handler["client"] = null
const model = handler.getModel()
- expect(model.info.supportsNativeTools).toBe(true)
- expect(model.info.defaultToolProtocol).toBe("native")
+ expect(model.info).toBeDefined()
+ })
+ })
+
+ describe("countTokens", () => {
+ beforeEach(() => {
+ handler["client"] = mockLanguageModelChat
+ })
+
+ it("should count tokens when called outside of an active request", async () => {
+ // Ensure no active request cancellation token exists
+ handler["currentRequestCancellation"] = null
+
+ mockLanguageModelChat.countTokens.mockResolvedValueOnce(42)
+
+ const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "Hello world" }]
+ const result = await handler.countTokens(content)
+
+ expect(result).toBe(42)
+ expect(mockLanguageModelChat.countTokens).toHaveBeenCalledWith("Hello world", expect.any(Object))
+ })
+
+ it("should count tokens when called during an active request", async () => {
+ // Simulate an active request with a cancellation token
+ const mockCancellation = {
+ token: { isCancellationRequested: false, onCancellationRequested: vi.fn() },
+ cancel: vi.fn(),
+ dispose: vi.fn(),
+ }
+ handler["currentRequestCancellation"] = mockCancellation as any
+
+ mockLanguageModelChat.countTokens.mockResolvedValueOnce(50)
+
+ const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "Test content" }]
+ const result = await handler.countTokens(content)
+
+ expect(result).toBe(50)
+ expect(mockLanguageModelChat.countTokens).toHaveBeenCalledWith("Test content", mockCancellation.token)
+ })
+
+ it("should return 0 when no client is available", async () => {
+ handler["client"] = null
+ handler["currentRequestCancellation"] = null
+
+ const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "Hello" }]
+ const result = await handler.countTokens(content)
+
+ expect(result).toBe(0)
+ })
+
+ it("should handle image blocks with placeholder", async () => {
+ handler["currentRequestCancellation"] = null
+ mockLanguageModelChat.countTokens.mockResolvedValueOnce(5)
+
+ const content: Anthropic.Messages.ContentBlockParam[] = [
+ { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
+ ]
+ const result = await handler.countTokens(content)
+
+ expect(result).toBe(5)
+ expect(mockLanguageModelChat.countTokens).toHaveBeenCalledWith("[IMAGE]", expect.any(Object))
})
})
diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts
index 119e869e6f..c622c9d4fc 100644
--- a/src/api/providers/__tests__/xai.spec.ts
+++ b/src/api/providers/__tests__/xai.spec.ts
@@ -339,7 +339,7 @@ describe("XAIHandler", () => {
}),
}),
]),
- parallel_tool_calls: false,
+ parallel_tool_calls: true,
}),
)
})
@@ -371,7 +371,7 @@ describe("XAIHandler", () => {
)
})
- it("should not include tools when toolProtocol is xml", async () => {
+ it("should always include tools and tool_choice (tools are guaranteed to be present after ALWAYS_AVAILABLE_TOOLS)", async () => {
const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" })
mockCreate.mockImplementationOnce(() => {
@@ -386,14 +386,14 @@ describe("XAIHandler", () => {
const messageGenerator = handlerWithTools.createMessage("test prompt", [], {
taskId: "test-task-id",
- tools: testTools,
- toolProtocol: "xml",
})
await messageGenerator.next()
+ // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0]
- expect(callArgs).not.toHaveProperty("tools")
- expect(callArgs).not.toHaveProperty("tool_choice")
+ expect(callArgs).toHaveProperty("tools")
+ expect(callArgs).toHaveProperty("tool_choice")
+ expect(callArgs).toHaveProperty("parallel_tool_calls", true)
})
it("should yield tool_call_partial chunks during streaming", async () => {
diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts
index 977ce5cbde..63daf8a3aa 100644
--- a/src/api/providers/anthropic-vertex.ts
+++ b/src/api/providers/anthropic-vertex.ts
@@ -8,7 +8,6 @@ import {
vertexDefaultModelId,
vertexModels,
ANTHROPIC_DEFAULT_MAX_TOKENS,
- TOOL_PROTOCOL,
VERTEX_1M_CONTEXT_MODEL_IDS,
} from "@roo-code/types"
import { safeJsonParse } from "@roo-code/core"
@@ -19,7 +18,6 @@ import { ApiStream } from "../transform/stream"
import { addCacheBreakpoints } from "../transform/caching/vertex"
import { getModelParams } from "../transform/model-params"
import { filterNonAnthropicBlocks } from "../transform/anthropic-filter"
-import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
import {
convertOpenAIToolsToAnthropic,
convertOpenAIToolChoiceToAnthropic,
@@ -77,22 +75,10 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple
// Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API
const sanitizedMessages = filterNonAnthropicBlocks(messages)
- // Enable native tools using resolveToolProtocol (which checks model's defaultToolProtocol)
- // This matches the approach used in AnthropicHandler
- // Also exclude tools when tool_choice is "none" since that means "don't use tools"
- const toolProtocol = resolveToolProtocol(this.options, info, metadata?.toolProtocol)
- const shouldIncludeNativeTools =
- metadata?.tools &&
- metadata.tools.length > 0 &&
- toolProtocol === TOOL_PROTOCOL.NATIVE &&
- metadata?.tool_choice !== "none"
-
- const nativeToolParams = shouldIncludeNativeTools
- ? {
- tools: convertOpenAIToolsToAnthropic(metadata.tools!),
- tool_choice: convertOpenAIToolChoiceToAnthropic(metadata.tool_choice, metadata.parallelToolCalls),
- }
- : {}
+ const nativeToolParams = {
+ tools: convertOpenAIToolsToAnthropic(metadata?.tools ?? []),
+ tool_choice: convertOpenAIToolChoiceToAnthropic(metadata?.tool_choice, metadata?.parallelToolCalls),
+ }
/**
* Vertex API has specific limitations for prompt caching:
diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts
index 4faf341d28..3139f5d25a 100644
--- a/src/api/providers/anthropic.ts
+++ b/src/api/providers/anthropic.ts
@@ -10,7 +10,6 @@ import {
anthropicModels,
ANTHROPIC_DEFAULT_MAX_TOKENS,
ApiProviderError,
- TOOL_PROTOCOL,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
@@ -19,7 +18,6 @@ import type { ApiHandlerOptions } from "../../shared/api"
import { ApiStream } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { filterNonAnthropicBlocks } from "../transform/anthropic-filter"
-import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
import { handleProviderError } from "./utils/error-handler"
import { BaseProvider } from "./base-provider"
@@ -74,24 +72,10 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
betas.push("context-1m-2025-08-07")
}
- // Enable native tools by default using resolveToolProtocol (which checks model's defaultToolProtocol)
- // This matches OpenRouter's approach of always including tools when provided
- // Also exclude tools when tool_choice is "none" since that means "don't use tools"
- // IMPORTANT: Use metadata.toolProtocol if provided (task's locked protocol) for consistency
- const model = this.getModel()
- const toolProtocol = resolveToolProtocol(this.options, model.info, metadata?.toolProtocol)
- const shouldIncludeNativeTools =
- metadata?.tools &&
- metadata.tools.length > 0 &&
- toolProtocol === TOOL_PROTOCOL.NATIVE &&
- metadata?.tool_choice !== "none"
-
- const nativeToolParams = shouldIncludeNativeTools
- ? {
- tools: convertOpenAIToolsToAnthropic(metadata.tools!),
- tool_choice: convertOpenAIToolChoiceToAnthropic(metadata.tool_choice, metadata.parallelToolCalls),
- }
- : {}
+ const nativeToolParams = {
+ tools: convertOpenAIToolsToAnthropic(metadata?.tools ?? []),
+ tool_choice: convertOpenAIToolChoiceToAnthropic(metadata?.tool_choice, metadata?.parallelToolCalls),
+ }
switch (modelId) {
case "claude-sonnet-4-5":
diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts
index a2a55cdc10..fc3d769ae2 100644
--- a/src/api/providers/base-openai-compatible-provider.ts
+++ b/src/api/providers/base-openai-compatible-provider.ts
@@ -4,7 +4,7 @@ import OpenAI from "openai"
import type { ModelInfo } from "@roo-code/types"
import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/api"
-import { XmlMatcher } from "../../utils/xml-matcher"
+import { TagMatcher } from "../../utils/tag-matcher"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { convertToOpenAiMessages } from "../transform/openai-format"
@@ -93,11 +93,9 @@ export abstract class BaseOpenAiCompatibleProvider
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
- ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(metadata?.toolProtocol === "native" && {
- parallel_tool_calls: metadata.parallelToolCalls ?? false,
- }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
// Add thinking parameter if reasoning is enabled and model supports it
@@ -119,7 +117,7 @@ export abstract class BaseOpenAiCompatibleProvider
): ApiStream {
const stream = await this.createStream(systemPrompt, messages, metadata)
- const matcher = new XmlMatcher(
+ const matcher = new TagMatcher(
"think",
(chunk) =>
({
diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts
index 761500750d..2b96a277f3 100644
--- a/src/api/providers/bedrock.ts
+++ b/src/api/providers/bedrock.ts
@@ -359,15 +359,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
const modelConfig = this.getModel()
const usePromptCache = Boolean(this.options.awsUsePromptCache && this.supportsAwsPromptCache(modelConfig))
- // Determine early if native tools should be used (needed for message conversion)
- const supportsNativeTools = modelConfig.info.supportsNativeTools ?? false
- const useNativeTools =
- supportsNativeTools &&
- metadata?.tools &&
- metadata.tools.length > 0 &&
- metadata?.toolProtocol !== "xml" &&
- metadata?.tool_choice !== "none"
-
const conversationId =
messages.length > 0
? `conv_${messages[0].role}_${
@@ -383,7 +374,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
usePromptCache,
modelConfig.info,
conversationId,
- useNativeTools,
)
let additionalModelRequestFields: BedrockAdditionalModelFields | undefined
@@ -424,29 +414,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
const is1MContextEnabled =
BEDROCK_1M_CONTEXT_MODEL_IDS.includes(baseModelId as any) && this.options.awsBedrock1MContext
- // Add anthropic_beta headers for various features
- // Start with an empty array and add betas as needed
- const anthropicBetas: string[] = []
-
- // Add 1M context beta if enabled
- if (is1MContextEnabled) {
- anthropicBetas.push("context-1m-2025-08-07")
- }
-
- // Add fine-grained tool streaming beta when native tools are used with Claude models
- // This enables proper tool use streaming for Anthropic models on Bedrock
- if (useNativeTools && baseModelId.includes("claude")) {
- anthropicBetas.push("fine-grained-tool-streaming-2025-05-14")
- }
-
- // Apply anthropic_beta to additionalModelRequestFields if any betas are needed
- if (anthropicBetas.length > 0) {
- if (!additionalModelRequestFields) {
- additionalModelRequestFields = {} as BedrockAdditionalModelFields
- }
- additionalModelRequestFields.anthropic_beta = anthropicBetas
- }
-
// Determine if service tier should be applied (checked later when building payload)
const useServiceTier =
this.options.awsBedrockServiceTier && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(baseModelId as any)
@@ -458,13 +425,32 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
})
}
- // Build tool configuration if native tools are enabled
- let toolConfig: ToolConfiguration | undefined
- if (useNativeTools && metadata?.tools) {
- toolConfig = {
- tools: this.convertToolsForBedrock(metadata.tools),
- toolChoice: this.convertToolChoiceForBedrock(metadata.tool_choice),
+ // Add anthropic_beta headers for various features
+ // Start with an empty array and add betas as needed
+ const anthropicBetas: string[] = []
+
+ // Add 1M context beta if enabled
+ if (is1MContextEnabled) {
+ anthropicBetas.push("context-1m-2025-08-07")
+ }
+
+ // Add fine-grained tool streaming beta for Claude models
+ // This enables proper tool use streaming for Anthropic models on Bedrock
+ if (baseModelId.includes("claude")) {
+ anthropicBetas.push("fine-grained-tool-streaming-2025-05-14")
+ }
+
+ // Apply anthropic_beta to additionalModelRequestFields if any betas are needed
+ if (anthropicBetas.length > 0) {
+ if (!additionalModelRequestFields) {
+ additionalModelRequestFields = {} as BedrockAdditionalModelFields
}
+ additionalModelRequestFields.anthropic_beta = anthropicBetas
+ }
+
+ const toolConfig: ToolConfiguration = {
+ tools: this.convertToolsForBedrock(metadata?.tools ?? []),
+ toolChoice: this.convertToolChoiceForBedrock(metadata?.tool_choice),
}
// Build payload with optional service_tier at top level
@@ -478,7 +464,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
...(additionalModelRequestFields && { additionalModelRequestFields }),
// Add anthropic_version at top level when using thinking features
...(thinkingEnabled && { anthropic_version: "bedrock-2023-05-31" }),
- ...(toolConfig && { toolConfig }),
+ toolConfig,
// Add service_tier as a top-level parameter (not inside additionalModelRequestFields)
...(useServiceTier && { service_tier: this.options.awsBedrockServiceTier }),
}
@@ -844,12 +830,9 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
usePromptCache: boolean = false,
modelInfo?: any,
conversationId?: string, // Optional conversation ID to track cache points across messages
- useNativeTools: boolean = false, // Whether native tool calling is being used
): { system: SystemContentBlock[]; messages: Message[] } {
// First convert messages using shared converter for proper image handling
- const convertedMessages = sharedConverter(anthropicMessages as Anthropic.Messages.MessageParam[], {
- useNativeTools,
- })
+ const convertedMessages = sharedConverter(anthropicMessages as Anthropic.Messages.MessageParam[])
// If prompt caching is disabled, return the converted messages directly
if (!usePromptCache) {
@@ -1360,8 +1343,6 @@ Please verify:
2. If using a provisioned model, check its throughput settings
3. Contact AWS support to request a quota increase if needed
-
-
`,
logLevel: "error",
},
diff --git a/src/api/providers/cerebras.ts b/src/api/providers/cerebras.ts
index 25e4f32f04..de1a4b2dbb 100644
--- a/src/api/providers/cerebras.ts
+++ b/src/api/providers/cerebras.ts
@@ -1,367 +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 { XmlMatcher } from "../../utils/xml-matcher"
-import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index"
-import { BaseProvider } from "./base-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 { t } from "../../i18n"
-
-const CEREBRAS_BASE_URL = "https://api.cerebras.ai/v1"
-const CEREBRAS_DEFAULT_TEMPERATURE = 0
+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")
- }
- }
-
- 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 convertToolSchemaForOpenAI to remove unsupported schema fields for Cerebras.
- * Cerebras doesn't support minItems/maxItems in array schemas with strict mode.
- */
- protected override convertToolSchemaForOpenAI(schema: any): any {
- const converted = super.convertToolSchemaForOpenAI(schema)
- return this.stripUnsupportedSchemaFields(converted)
- }
-
- /**
- * Recursively strips unsupported schema fields for Cerebras.
- * Cerebras strict mode doesn't support minItems, maxItems on arrays.
- */
- private stripUnsupportedSchemaFields(schema: any): any {
- if (!schema || typeof schema !== "object") {
- return schema
- }
-
- 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)
- }
-
- 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).
- */
- 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),
- },
- }
+ // Create the Cerebras provider using AI SDK
+ this.provider = createCerebras({
+ apiKey: options.cerebrasApiKey ?? "not-provided",
+ headers: {
+ ...DEFAULT_HEADERS,
+ [CEREBRAS_INTEGRATION_HEADER]: CEREBRAS_INTEGRATION_NAME,
+ },
})
}
- async *createMessage(
+ 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 }
+ }
+
+ /**
+ * 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
+ }
+ }): ApiStreamUsageChunk {
+ return {
+ type: "usage",
+ inputTokens: usage.inputTokens || 0,
+ outputTokens: usage.outputTokens || 0,
+ cacheReadTokens: usage.details?.cachedInputTokens,
+ 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 { id: model, info: modelInfo } = this.getModel()
- const max_tokens = modelInfo.maxTokens
- const supportsNativeTools = modelInfo.supportsNativeTools ?? false
- const temperature = this.options.modelTemperature ?? CEREBRAS_DEFAULT_TEMPERATURE
+ const { temperature } = this.getModel()
+ const languageModel = this.getLanguageModel()
- // Check if we should use native tool calling
- const useNativeTools =
- supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml"
+ // Convert messages to AI SDK format
+ const aiSdkMessages = convertToAiSdkMessages(messages)
- // Convert Anthropic messages to OpenAI format (Cerebras is OpenAI-compatible)
- const openaiMessages = convertToOpenAiMessages(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
- // 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
- ...(useNativeTools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(useNativeTools && metadata.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(useNativeTools && { parallel_tool_calls: metadata?.parallelToolCalls ?? false }),
+ // 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),
}
+ // Use streamText for streaming responses
+ const result = streamText(requestOptions)
+
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 }),
- )
+ // 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 (!response.body) {
- throw new Error(t("common:errors.cerebras.noResponseBody"))
- }
-
- // Initialize XmlMatcher to parse ... tags
- const matcher = new XmlMatcher(
- "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
-
- // Handle text content - parse for thinking tokens
- if (delta?.content) {
- const content = delta.content
-
- // Use XmlMatcher 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
- }
- }
- }
- } 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): Promise {
- const { id: model } = this.getModel()
+ const { temperature } = this.getModel()
+ const languageModel = this.getLanguageModel()
- // Prepare request body for non-streaming completion
- const requestBody = {
- model,
- messages: [{ role: "user", content: prompt }],
- stream: false,
- }
+ const { text } = await generateText({
+ model: languageModel,
+ prompt,
+ maxOutputTokens: this.getMaxOutputTokens(),
+ temperature: this.options.modelTemperature ?? temperature ?? CEREBRAS_DEFAULT_TEMPERATURE,
+ })
- 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()
-
- // 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
- }
- }
-
- 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/chutes.ts b/src/api/providers/chutes.ts
index 78ac7e591f..6b040834cd 100644
--- a/src/api/providers/chutes.ts
+++ b/src/api/providers/chutes.ts
@@ -4,7 +4,7 @@ import OpenAI from "openai"
import type { ApiHandlerOptions } from "../../shared/api"
import { getModelMaxOutputTokens } from "../../shared/api"
-import { XmlMatcher } from "../../utils/xml-matcher"
+import { TagMatcher } from "../../utils/tag-matcher"
import { convertToR1Format } from "../transform/r1-format"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@@ -47,8 +47,8 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
- ...(metadata?.tools && { tools: metadata.tools }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
+ tools: metadata?.tools,
+ tool_choice: metadata?.tool_choice,
}
// Only add temperature if model supports it
@@ -72,7 +72,7 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
})
- const matcher = new XmlMatcher(
+ const matcher = new TagMatcher(
"think",
(chunk) =>
({
diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts
deleted file mode 100644
index f2bccc329c..0000000000
--- a/src/api/providers/claude-code.ts
+++ /dev/null
@@ -1,389 +0,0 @@
-import type { Anthropic } from "@anthropic-ai/sdk"
-import OpenAI from "openai"
-import {
- claudeCodeDefaultModelId,
- type ClaudeCodeModelId,
- claudeCodeModels,
- claudeCodeReasoningConfig,
- type ClaudeCodeReasoningLevel,
- type ModelInfo,
-} from "@roo-code/types"
-import { type ApiHandler, ApiHandlerCreateMessageMetadata, type SingleCompletionHandler } from ".."
-import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
-import { claudeCodeOAuthManager, generateUserId } from "../../integrations/claude-code/oauth"
-import {
- createStreamingMessage,
- type StreamChunk,
- type ThinkingConfig,
-} from "../../integrations/claude-code/streaming-client"
-import { t } from "../../i18n"
-import { ApiHandlerOptions } from "../../shared/api"
-import { countTokens } from "../../utils/countTokens"
-import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-tools/converters"
-
-/**
- * Converts OpenAI tool_choice to Anthropic ToolChoice format
- * @param toolChoice - OpenAI tool_choice parameter
- * @param parallelToolCalls - When true, allows parallel tool calls. When false (default), disables parallel tool calls.
- */
-function convertOpenAIToolChoice(
- toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"],
- parallelToolCalls?: boolean,
-): Anthropic.Messages.MessageCreateParams["tool_choice"] | undefined {
- // Anthropic allows parallel tool calls by default. When parallelToolCalls is false or undefined,
- // we disable parallel tool use to ensure one tool call at a time.
- const disableParallelToolUse = !parallelToolCalls
-
- if (!toolChoice) {
- // Default to auto with parallel tool use control
- return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
- }
-
- if (typeof toolChoice === "string") {
- switch (toolChoice) {
- case "none":
- return undefined // Anthropic doesn't have "none", just omit tools
- case "auto":
- return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
- case "required":
- return { type: "any", disable_parallel_tool_use: disableParallelToolUse }
- default:
- return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
- }
- }
-
- // Handle object form { type: "function", function: { name: string } }
- if (typeof toolChoice === "object" && "function" in toolChoice) {
- return {
- type: "tool",
- name: toolChoice.function.name,
- disable_parallel_tool_use: disableParallelToolUse,
- }
- }
-
- return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
-}
-
-export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler {
- private options: ApiHandlerOptions
- /**
- * Store the last thinking block signature for interleaved thinking with tool use.
- * This is captured from thinking_complete events during streaming and
- * must be passed back to the API when providing tool results.
- * Similar to Gemini's thoughtSignature pattern.
- */
- private lastThinkingSignature?: string
-
- constructor(options: ApiHandlerOptions) {
- this.options = options
- }
-
- /**
- * Get the thinking signature from the last response.
- * Used by Task.addToApiConversationHistory to persist the signature
- * so it can be passed back to the API for tool use continuations.
- * This follows the same pattern as Gemini's getThoughtSignature().
- */
- public getThoughtSignature(): string | undefined {
- return this.lastThinkingSignature
- }
-
- /**
- * Gets the reasoning effort level for the current request.
- * Returns the effective reasoning level (low/medium/high) or null if disabled.
- */
- private getReasoningEffort(modelInfo: ModelInfo): ClaudeCodeReasoningLevel | null {
- // Check if reasoning is explicitly disabled
- if (this.options.enableReasoningEffort === false) {
- return null
- }
-
- // Get the selected effort from settings or model default
- const selectedEffort = this.options.reasoningEffort ?? modelInfo.reasoningEffort
-
- // "disable" or no selection means no reasoning
- if (!selectedEffort || selectedEffort === "disable") {
- return null
- }
-
- // Only allow valid levels for Claude Code
- if (selectedEffort === "low" || selectedEffort === "medium" || selectedEffort === "high") {
- return selectedEffort
- }
-
- return null
- }
-
- async *createMessage(
- systemPrompt: string,
- messages: Anthropic.Messages.MessageParam[],
- metadata?: ApiHandlerCreateMessageMetadata,
- ): ApiStream {
- // Reset per-request state that we persist into apiConversationHistory
- this.lastThinkingSignature = undefined
-
- const buildNotAuthenticatedError = () =>
- new Error(
- t("common:errors.claudeCode.notAuthenticated", {
- defaultValue:
- "Not authenticated with Claude Code. Please sign in using the Claude Code OAuth flow.",
- }),
- )
-
- async function* streamOnce(this: ClaudeCodeHandler, accessToken: string): ApiStream {
- // Get user email for generating user_id metadata
- const email = await claudeCodeOAuthManager.getEmail()
-
- const model = this.getModel()
-
- // Validate that the model ID is a valid ClaudeCodeModelId
- const modelId = Object.hasOwn(claudeCodeModels, model.id)
- ? (model.id as ClaudeCodeModelId)
- : claudeCodeDefaultModelId
-
- // Generate user_id metadata in the format required by Claude Code API
- const userId = generateUserId(email || undefined)
-
- // Convert OpenAI tools to Anthropic format if provided and protocol is native
- // Exclude tools when tool_choice is "none" since that means "don't use tools"
- const shouldIncludeNativeTools =
- metadata?.tools &&
- metadata.tools.length > 0 &&
- metadata?.toolProtocol !== "xml" &&
- metadata?.tool_choice !== "none"
-
- const anthropicTools = shouldIncludeNativeTools ? convertOpenAIToolsToAnthropic(metadata.tools!) : undefined
-
- const anthropicToolChoice = shouldIncludeNativeTools
- ? convertOpenAIToolChoice(metadata.tool_choice, metadata.parallelToolCalls)
- : undefined
-
- // Determine reasoning effort and thinking configuration
- const reasoningLevel = this.getReasoningEffort(model.info)
-
- let thinking: ThinkingConfig
- // With interleaved thinking (enabled via beta header), budget_tokens can exceed max_tokens
- // as the token limit becomes the entire context window. We use the model's maxTokens.
- // See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking
- const maxTokens = model.info.maxTokens ?? 16384
-
- if (reasoningLevel) {
- // Use thinking mode with budget_tokens from config
- const config = claudeCodeReasoningConfig[reasoningLevel]
- thinking = {
- type: "enabled",
- budget_tokens: config.budgetTokens,
- }
- } else {
- // Explicitly disable thinking
- thinking = { type: "disabled" }
- }
-
- // Create streaming request using OAuth
- const stream = createStreamingMessage({
- accessToken,
- model: modelId,
- systemPrompt,
- messages,
- maxTokens,
- thinking,
- tools: anthropicTools,
- toolChoice: anthropicToolChoice,
- metadata: {
- user_id: userId,
- },
- })
-
- // Track usage for cost calculation
- let inputTokens = 0
- let outputTokens = 0
- let cacheReadTokens = 0
- let cacheWriteTokens = 0
-
- for await (const chunk of stream) {
- switch (chunk.type) {
- case "text":
- yield {
- type: "text",
- text: chunk.text,
- }
- break
-
- case "reasoning":
- yield {
- type: "reasoning",
- text: chunk.text,
- }
- break
-
- case "thinking_complete":
- // Capture the signature for persistence in api_conversation_history
- // This enables tool use continuations where thinking blocks must be passed back
- if (chunk.signature) {
- this.lastThinkingSignature = chunk.signature
- }
- // Emit a complete thinking block with signature
- // This is critical for interleaved thinking with tool use
- // The signature must be included when passing thinking blocks back to the API
- yield {
- type: "reasoning",
- text: chunk.thinking,
- signature: chunk.signature,
- }
- break
-
- case "tool_call_partial":
- yield {
- type: "tool_call_partial",
- index: chunk.index,
- id: chunk.id,
- name: chunk.name,
- arguments: chunk.arguments,
- }
- break
-
- case "usage": {
- inputTokens = chunk.inputTokens
- outputTokens = chunk.outputTokens
- cacheReadTokens = chunk.cacheReadTokens || 0
- cacheWriteTokens = chunk.cacheWriteTokens || 0
-
- // Claude Code is subscription-based, no per-token cost
- const usageChunk: ApiStreamUsageChunk = {
- type: "usage",
- inputTokens,
- outputTokens,
- cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
- cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
- totalCost: 0,
- }
-
- yield usageChunk
- break
- }
-
- case "error":
- throw new Error(chunk.error)
- }
- }
- }
-
- // Get access token from OAuth manager
- let accessToken = await claudeCodeOAuthManager.getAccessToken()
- if (!accessToken) {
- throw buildNotAuthenticatedError()
- }
-
- // Try the request with at most one force-refresh retry on auth failure
- for (let attempt = 0; attempt < 2; attempt++) {
- try {
- yield* streamOnce.call(this, accessToken)
- return
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- const isAuthFailure = /unauthorized|invalid token|not authenticated|authentication/i.test(message)
-
- // Only retry on auth failure during first attempt
- const canRetry = attempt === 0 && isAuthFailure
- if (!canRetry) {
- throw error
- }
-
- // Force refresh the token for retry
- const refreshed = await claudeCodeOAuthManager.forceRefreshAccessToken()
- if (!refreshed) {
- throw buildNotAuthenticatedError()
- }
- accessToken = refreshed
- }
- }
-
- // Unreachable: loop always returns on success or throws on failure
- throw buildNotAuthenticatedError()
- }
-
- getModel(): { id: string; info: ModelInfo } {
- const modelId = this.options.apiModelId
- if (modelId && Object.hasOwn(claudeCodeModels, modelId)) {
- const id = modelId as ClaudeCodeModelId
- return { id, info: { ...claudeCodeModels[id] } }
- }
-
- return {
- id: claudeCodeDefaultModelId,
- info: { ...claudeCodeModels[claudeCodeDefaultModelId] },
- }
- }
-
- async countTokens(content: Anthropic.Messages.ContentBlockParam[]): Promise {
- if (content.length === 0) {
- return 0
- }
- return countTokens(content, { useWorker: true })
- }
-
- /**
- * Completes a prompt using the Claude Code API.
- * This is used for context condensing and prompt enhancement.
- * The Claude Code branding is automatically prepended by createStreamingMessage.
- */
- async completePrompt(prompt: string): Promise {
- // Get access token from OAuth manager
- const accessToken = await claudeCodeOAuthManager.getAccessToken()
-
- if (!accessToken) {
- throw new Error(
- t("common:errors.claudeCode.notAuthenticated", {
- defaultValue:
- "Not authenticated with Claude Code. Please sign in using the Claude Code OAuth flow.",
- }),
- )
- }
-
- // Get user email for generating user_id metadata
- const email = await claudeCodeOAuthManager.getEmail()
-
- const model = this.getModel()
-
- // Validate that the model ID is a valid ClaudeCodeModelId
- const modelId = Object.hasOwn(claudeCodeModels, model.id)
- ? (model.id as ClaudeCodeModelId)
- : claudeCodeDefaultModelId
-
- // Generate user_id metadata in the format required by Claude Code API
- const userId = generateUserId(email || undefined)
-
- // Use maxTokens from model info for completion
- const maxTokens = model.info.maxTokens ?? 16384
-
- // Create streaming request using OAuth
- // The system prompt is empty here since the prompt itself contains all context
- // createStreamingMessage will still prepend the Claude Code branding
- const stream = createStreamingMessage({
- accessToken,
- model: modelId,
- systemPrompt: "", // Empty system prompt - the prompt text contains all necessary context
- messages: [{ role: "user", content: prompt }],
- maxTokens,
- thinking: { type: "disabled" }, // No thinking for simple completions
- metadata: {
- user_id: userId,
- },
- })
-
- // Collect all text chunks into a single response
- let result = ""
-
- for await (const chunk of stream) {
- switch (chunk.type) {
- case "text":
- result += chunk.text
- break
- case "error":
- throw new Error(chunk.error)
- }
- }
-
- return result
- }
-}
diff --git a/src/api/providers/deepinfra.ts b/src/api/providers/deepinfra.ts
index 4dfad2689a..e5b10e4e44 100644
--- a/src/api/providers/deepinfra.ts
+++ b/src/api/providers/deepinfra.ts
@@ -65,11 +65,6 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion
prompt_cache_key = _metadata.taskId
}
- // Check if model supports native tools and tools are provided with native protocol
- const supportsNativeTools = info.supportsNativeTools ?? false
- const useNativeTools =
- supportsNativeTools && _metadata?.tools && _metadata.tools.length > 0 && _metadata?.toolProtocol !== "xml"
-
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelId,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
@@ -77,9 +72,9 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion
stream_options: { include_usage: true },
reasoning_effort,
prompt_cache_key,
- ...(useNativeTools && { tools: this.convertToolsForOpenAI(_metadata.tools) }),
- ...(useNativeTools && _metadata.tool_choice && { tool_choice: _metadata.tool_choice }),
- ...(useNativeTools && { parallel_tool_calls: _metadata?.parallelToolCalls ?? false }),
+ tools: this.convertToolsForOpenAI(_metadata?.tools),
+ tool_choice: _metadata?.tool_choice,
+ parallel_tool_calls: _metadata?.parallelToolCalls ?? true,
} as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
if (this.supportsTemperature(modelId)) {
diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts
index 4e5aef23a5..ba9c9d47e3 100644
--- a/src/api/providers/deepseek.ts
+++ b/src/api/providers/deepseek.ts
@@ -1,152 +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()
+ const { temperature } = this.getModel()
+ const languageModel = this.getLanguageModel()
- // Check if this is a thinking-enabled model (deepseek-reasoner)
- const isThinkingModel = modelId.includes("deepseek-reasoner")
+ // Convert messages to AI SDK format
+ const aiSdkMessages = convertToAiSdkMessages(messages)
- // 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,
+ // 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),
+ }
+
+ // 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, "DeepSeek")
+ }
+ }
+
+ /**
+ * 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,
})
- 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" } }),
- ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(metadata?.toolProtocol === "native" && {
- parallel_tool_calls: metadata.parallelToolCalls ?? false,
- }),
- }
-
- // Add max_tokens if needed
- this.addMaxTokensIfNeeded(requestOptions, modelInfo)
-
- // 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,
- }
- }
- }
-
- if (chunk.usage) {
- lastUsage = chunk.usage
- }
- }
-
- if (lastUsage) {
- yield this.processUsageMetrics(lastUsage, modelInfo)
- }
- }
-
- // 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,
- }
+ return text
}
}
diff --git a/src/api/providers/featherless.ts b/src/api/providers/featherless.ts
index 3dcd0821b8..6a94fce983 100644
--- a/src/api/providers/featherless.ts
+++ b/src/api/providers/featherless.ts
@@ -8,7 +8,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import type { ApiHandlerOptions } from "../../shared/api"
-import { XmlMatcher } from "../../utils/xml-matcher"
+import { TagMatcher } from "../../utils/tag-matcher"
import { convertToR1Format } from "../transform/r1-format"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@@ -63,7 +63,7 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider
({
diff --git a/src/api/providers/fetchers/__tests__/chutes.spec.ts b/src/api/providers/fetchers/__tests__/chutes.spec.ts
index 79ed027383..009cf0493f 100644
--- a/src/api/providers/fetchers/__tests__/chutes.spec.ts
+++ b/src/api/providers/fetchers/__tests__/chutes.spec.ts
@@ -51,7 +51,6 @@ describe("getChutesModels", () => {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: false,
inputPrice: 0,
outputPrice: 0,
description: "Chutes AI model: test/new-model",
@@ -162,7 +161,7 @@ describe("getChutesModels", () => {
expect(models["test/image-model"].supportsImages).toBe(true)
})
- it("should detect native tool support from supported_features", async () => {
+ it("should accept supported_features containing tools", async () => {
const mockResponse = {
data: {
data: [
@@ -184,10 +183,11 @@ describe("getChutesModels", () => {
const models = await getChutesModels("test-api-key")
- expect(models["test/tools-model"].supportsNativeTools).toBe(true)
+ expect(models["test/tools-model"]).toBeDefined()
+ expect(models["test/tools-model"].contextWindow).toBe(128000)
})
- it("should not enable native tool support when tools is not in supported_features", async () => {
+ it("should accept supported_features without tools", async () => {
const mockResponse = {
data: {
data: [
@@ -209,8 +209,8 @@ describe("getChutesModels", () => {
const models = await getChutesModels("test-api-key")
- expect(models["test/no-tools-model"].supportsNativeTools).toBe(false)
- expect(models["test/no-tools-model"].defaultToolProtocol).toBeUndefined()
+ expect(models["test/no-tools-model"]).toBeDefined()
+ expect(models["test/no-tools-model"].contextWindow).toBe(128000)
})
it("should skip empty objects in API response and still process valid models", async () => {
@@ -336,7 +336,6 @@ describe("getChutesModels", () => {
// Both valid models should be processed
expect(models["test/valid-1"]).toBeDefined()
expect(models["test/valid-2"]).toBeDefined()
- expect(models["test/valid-2"].supportsNativeTools).toBe(true)
consoleErrorSpy.mockRestore()
})
diff --git a/src/api/providers/fetchers/__tests__/litellm.spec.ts b/src/api/providers/fetchers/__tests__/litellm.spec.ts
index fe6424e673..c05cda8839 100644
--- a/src/api/providers/fetchers/__tests__/litellm.spec.ts
+++ b/src/api/providers/fetchers/__tests__/litellm.spec.ts
@@ -222,7 +222,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: undefined,
@@ -234,7 +233,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: 10,
outputPrice: 30,
cacheWritesPrice: undefined,
@@ -305,7 +303,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: undefined,
outputPrice: undefined,
cacheWritesPrice: undefined,
@@ -318,7 +315,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 200000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: undefined,
outputPrice: undefined,
cacheWritesPrice: undefined,
@@ -455,7 +451,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: undefined,
outputPrice: undefined,
cacheWritesPrice: undefined,
@@ -468,7 +463,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: undefined,
outputPrice: undefined,
cacheWritesPrice: undefined,
@@ -533,7 +527,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: undefined,
outputPrice: undefined,
cacheWritesPrice: undefined,
@@ -546,7 +539,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: undefined,
outputPrice: undefined,
cacheWritesPrice: undefined,
@@ -559,7 +551,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: undefined,
outputPrice: undefined,
cacheWritesPrice: undefined,
@@ -673,7 +664,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: undefined,
outputPrice: undefined,
cacheWritesPrice: undefined,
@@ -687,7 +677,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: undefined,
outputPrice: undefined,
cacheWritesPrice: undefined,
@@ -701,7 +690,6 @@ describe("getLiteLLMModels", () => {
contextWindow: 100000,
supportsImages: false,
supportsPromptCache: false,
- supportsNativeTools: true,
inputPrice: undefined,
outputPrice: undefined,
cacheWritesPrice: undefined,
diff --git a/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts b/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts
index 966a6002d8..b5ff897ec4 100644
--- a/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts
+++ b/src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts
@@ -15,14 +15,13 @@ describe("modelEndpointCache", () => {
describe("getModelEndpoints", () => {
it("should copy model-level capabilities from parent model to endpoints", async () => {
- // Mock the parent model data with native tools support
+ // Mock the parent model data with capabilities
const mockParentModels = {
"anthropic/claude-sonnet-4": {
maxTokens: 8192,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
- supportsNativeTools: true, // Parent supports native tools
supportsReasoningEffort: true,
supportedParameters: ["max_tokens", "temperature", "reasoning"] as any,
inputPrice: 3,
@@ -39,7 +38,7 @@ describe("modelEndpointCache", () => {
supportsPromptCache: true,
inputPrice: 3,
outputPrice: 15,
- // Note: No supportsNativeTools, supportsReasoningEffort, or supportedParameters
+ // Note: No supportsReasoningEffort, or supportedParameters
},
"amazon-bedrock": {
maxTokens: 8192,
@@ -61,11 +60,9 @@ describe("modelEndpointCache", () => {
})
// Verify capabilities were copied from parent to ALL endpoints
- expect(result.anthropic.supportsNativeTools).toBe(true)
expect(result.anthropic.supportsReasoningEffort).toBe(true)
expect(result.anthropic.supportedParameters).toEqual(["max_tokens", "temperature", "reasoning"])
- expect(result["amazon-bedrock"].supportsNativeTools).toBe(true)
expect(result["amazon-bedrock"].supportsReasoningEffort).toBe(true)
expect(result["amazon-bedrock"].supportedParameters).toEqual(["max_tokens", "temperature", "reasoning"])
})
@@ -76,7 +73,6 @@ describe("modelEndpointCache", () => {
maxTokens: 1000,
contextWindow: 10000,
supportsPromptCache: false,
- supportsNativeTools: true,
supportedParameters: ["max_tokens", "temperature"] as any,
},
}
@@ -131,9 +127,9 @@ describe("modelEndpointCache", () => {
endpoint: "anthropic",
})
- // Should not crash, but capabilities will be undefined
+ // Should not crash, but copied capabilities will be undefined
expect(result.anthropic).toBeDefined()
- expect(result.anthropic.supportsNativeTools).toBeUndefined()
+ expect(result.anthropic.supportedParameters).toBeUndefined()
})
it("should return empty object for non-openrouter providers", async () => {
diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts
index fd4e2e80b8..59663bc495 100644
--- a/src/api/providers/fetchers/__tests__/ollama.test.ts
+++ b/src/api/providers/fetchers/__tests__/ollama.test.ts
@@ -22,7 +22,6 @@ describe("Ollama Fetcher", () => {
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 0,
outputPrice: 0,
cacheWritesPrice: 0,
@@ -47,7 +46,6 @@ describe("Ollama Fetcher", () => {
contextWindow: 40960,
supportsImages: false,
supportsPromptCache: true,
- supportsNativeTools: true,
inputPrice: 0,
outputPrice: 0,
cacheWritesPrice: 0,
@@ -77,7 +75,7 @@ describe("Ollama Fetcher", () => {
const parsedModel = parseOllamaModel(modelDataWithTools as any)
expect(parsedModel).not.toBeNull()
- expect(parsedModel!.supportsNativeTools).toBe(true)
+ expect(parsedModel!.contextWindow).toBeGreaterThan(0)
})
it("should return null when capabilities is undefined (no tool support)", () => {
@@ -114,7 +112,7 @@ describe("Ollama Fetcher", () => {
expect(parsedModel).not.toBeNull()
expect(parsedModel!.supportsImages).toBe(true)
- expect(parsedModel!.supportsNativeTools).toBe(true)
+ expect(parsedModel!.contextWindow).toBeGreaterThan(0)
})
})
diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts
index cbe3f35c8b..3bcd27716f 100644
--- a/src/api/providers/fetchers/__tests__/openrouter.spec.ts
+++ b/src/api/providers/fetchers/__tests__/openrouter.spec.ts
@@ -28,9 +28,7 @@ describe("OpenRouter API", () => {
description: expect.any(String),
supportsReasoningBudget: false,
supportsReasoningEffort: false,
- supportsNativeTools: true,
supportedParameters: ["max_tokens", "temperature", "reasoning", "include_reasoning"],
- defaultToolProtocol: "native",
})
expect(models["anthropic/claude-3.7-sonnet:thinking"]).toEqual({
@@ -46,9 +44,7 @@ describe("OpenRouter API", () => {
supportsReasoningBudget: true,
requiredReasoningBudget: true,
supportsReasoningEffort: true,
- supportsNativeTools: true,
supportedParameters: ["max_tokens", "temperature", "reasoning", "include_reasoning"],
- defaultToolProtocol: "native",
})
expect(models["google/gemini-2.5-flash-preview-05-20"].maxTokens).toEqual(65535)
@@ -136,7 +132,7 @@ describe("OpenRouter API", () => {
cacheWritesPrice: 1.625,
cacheReadsPrice: 0.31,
supportsReasoningEffort: true,
- supportsNativeTools: false, // Gemini doesn't support native tools via "tools" parameter
+ // Tool support is handled via metadata/tools at request time.
supportedParameters: ["max_tokens", "temperature", "reasoning"],
},
} as Record
@@ -150,7 +146,6 @@ describe("OpenRouter API", () => {
const parentModel = mockCachedModels["google/gemini-2.5-pro-preview"]
if (parentModel) {
for (const key of Object.keys(endpoints)) {
- endpoints[key].supportsNativeTools = parentModel.supportsNativeTools
endpoints[key].supportsReasoningEffort = parentModel.supportsReasoningEffort
endpoints[key].supportedParameters = parentModel.supportedParameters
}
@@ -169,7 +164,6 @@ describe("OpenRouter API", () => {
cacheReadsPrice: 0.31,
description: undefined,
supportsReasoningEffort: true,
- supportsNativeTools: false, // Copied from parent model
supportedParameters: ["max_tokens", "temperature", "reasoning"],
},
"google-ai-studio": {
@@ -184,7 +178,6 @@ describe("OpenRouter API", () => {
cacheReadsPrice: 0.31,
description: undefined,
supportsReasoningEffort: true,
- supportsNativeTools: false, // Copied from parent model
supportedParameters: ["max_tokens", "temperature", "reasoning"],
},
})
@@ -221,7 +214,7 @@ describe("OpenRouter API", () => {
},
}
- // Mock cached parent model with native tools support
+ // Mock cached parent model capabilities
const mockCachedModels = {
"anthropic/claude-sonnet-4": {
maxTokens: 8192,
@@ -234,7 +227,7 @@ describe("OpenRouter API", () => {
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
supportsReasoningEffort: true,
- supportsNativeTools: true, // Anthropic supports native tools
+ // Tool support is handled via metadata/tools at request time.
supportedParameters: ["max_tokens", "temperature", "reasoning"],
},
} as Record
@@ -248,7 +241,6 @@ describe("OpenRouter API", () => {
const parentModel = mockCachedModels["anthropic/claude-sonnet-4"]
if (parentModel) {
for (const key of Object.keys(endpoints)) {
- endpoints[key].supportsNativeTools = parentModel.supportsNativeTools
endpoints[key].supportsReasoningEffort = parentModel.supportsReasoningEffort
endpoints[key].supportedParameters = parentModel.supportedParameters
}
@@ -266,7 +258,6 @@ describe("OpenRouter API", () => {
description: undefined,
supportsReasoningBudget: true,
supportsReasoningEffort: true,
- supportsNativeTools: true, // Copied from parent model
supportedParameters: ["max_tokens", "temperature", "reasoning"],
})
@@ -393,7 +384,7 @@ describe("OpenRouter API", () => {
expect(imageResult.maxTokens).toBe(64000)
})
- it("sets defaultToolProtocol to native when model supports native tools", () => {
+ it("treats supportedParameters containing tools as allowed", () => {
const mockModel = {
name: "Tools Model",
description: "Model with native tool support",
@@ -414,11 +405,10 @@ describe("OpenRouter API", () => {
supportedParameters: ["tools", "max_tokens", "temperature"],
})
- expect(resultWithTools.supportsNativeTools).toBe(true)
- expect(resultWithTools.defaultToolProtocol).toBe("native")
+ expect(resultWithTools.supportedParameters).toContain("max_tokens")
})
- it("does not set defaultToolProtocol when model does not support native tools", () => {
+ it("treats supportedParameters without tools as allowed", () => {
const mockModel = {
name: "No Tools Model",
description: "Model without native tool support",
@@ -439,8 +429,7 @@ describe("OpenRouter API", () => {
supportedParameters: ["max_tokens", "temperature"],
})
- expect(resultWithoutTools.supportsNativeTools).toBe(false)
- expect(resultWithoutTools.defaultToolProtocol).toBeUndefined()
+ expect(resultWithoutTools.supportedParameters).toContain("max_tokens")
})
})
})
diff --git a/src/api/providers/fetchers/__tests__/roo.spec.ts b/src/api/providers/fetchers/__tests__/roo.spec.ts
index cd86be0b69..bb3b08b63f 100644
--- a/src/api/providers/fetchers/__tests__/roo.spec.ts
+++ b/src/api/providers/fetchers/__tests__/roo.spec.ts
@@ -69,7 +69,6 @@ describe("getRooModels", () => {
supportsImages: true,
supportsReasoningEffort: true,
requiredReasoningEffort: false,
- supportsNativeTools: false,
supportsPromptCache: true,
inputPrice: 100, // 0.0001 * 1_000_000
outputPrice: 200, // 0.0002 * 1_000_000
@@ -78,7 +77,6 @@ describe("getRooModels", () => {
description: "Fast coding model",
deprecated: false,
isFree: false,
- defaultToolProtocol: "native",
},
})
})
@@ -119,7 +117,6 @@ describe("getRooModels", () => {
supportsImages: false,
supportsReasoningEffort: true,
requiredReasoningEffort: true,
- supportsNativeTools: false,
supportsPromptCache: false,
inputPrice: 100, // 0.0001 * 1_000_000
outputPrice: 200, // 0.0002 * 1_000_000
@@ -129,7 +126,7 @@ describe("getRooModels", () => {
deprecated: false,
isFree: false,
defaultTemperature: undefined,
- defaultToolProtocol: "native",
+
isStealthModel: undefined,
})
})
@@ -169,7 +166,6 @@ describe("getRooModels", () => {
supportsImages: false,
supportsReasoningEffort: false,
requiredReasoningEffort: false,
- supportsNativeTools: false,
supportsPromptCache: false,
inputPrice: 100, // 0.0001 * 1_000_000
outputPrice: 200, // 0.0002 * 1_000_000
@@ -179,7 +175,7 @@ describe("getRooModels", () => {
deprecated: false,
isFree: false,
defaultTemperature: undefined,
- defaultToolProtocol: "native",
+
isStealthModel: undefined,
})
})
@@ -551,7 +547,7 @@ describe("getRooModels", () => {
expect(models["test/model-no-temp"].defaultTemperature).toBeUndefined()
})
- it("should set defaultToolProtocol to native when default-native-tools tag is present", async () => {
+ it("should include models when tool-use tags are present", async () => {
const mockResponse = {
object: "list",
data: [
@@ -581,11 +577,10 @@ describe("getRooModels", () => {
const models = await getRooModels(baseUrl, apiKey)
- expect(models["test/native-tools-model"].supportsNativeTools).toBe(true)
- expect(models["test/native-tools-model"].defaultToolProtocol).toBe("native")
+ expect(models["test/native-tools-model"]).toBeDefined()
})
- it("should set defaultToolProtocol to native for all models regardless of tags", async () => {
+ it("handles models when tool tags are absent", async () => {
const mockResponse = {
object: "list",
data: [
@@ -615,12 +610,10 @@ describe("getRooModels", () => {
const models = await getRooModels(baseUrl, apiKey)
- // All Roo provider models now default to native tool protocol
- expect(models["test/model-without-tool-tags"].supportsNativeTools).toBe(false)
- expect(models["test/model-without-tool-tags"].defaultToolProtocol).toBe("native")
+ expect(models["test/model-without-tool-tags"]).toBeDefined()
})
- it("should set supportsNativeTools from tool-use tag and always set defaultToolProtocol to native", async () => {
+ it("handles models with tool-use tag", async () => {
const mockResponse = {
object: "list",
data: [
@@ -650,9 +643,7 @@ describe("getRooModels", () => {
const models = await getRooModels(baseUrl, apiKey)
- // tool-use tag sets supportsNativeTools, and all models get defaultToolProtocol: native
- expect(models["test/tool-use-model"].supportsNativeTools).toBe(true)
- expect(models["test/tool-use-model"].defaultToolProtocol).toBe("native")
+ expect(models["test/tool-use-model"]).toBeDefined()
})
it("should detect stealth mode from tags", async () => {
diff --git a/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts
index 5c33116e5c..3a4a234de9 100644
--- a/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts
+++ b/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts
@@ -173,7 +173,6 @@ describe("Vercel AI Gateway Fetchers", () => {
maxTokens: 8000,
contextWindow: 100000,
supportsImages: false,
- supportsNativeTools: true,
supportsPromptCache: false,
inputPrice: 2500000,
outputPrice: 10000000,
diff --git a/src/api/providers/fetchers/__tests__/versionedSettings.spec.ts b/src/api/providers/fetchers/__tests__/versionedSettings.spec.ts
index 9422c01267..fcde78b94a 100644
--- a/src/api/providers/fetchers/__tests__/versionedSettings.spec.ts
+++ b/src/api/providers/fetchers/__tests__/versionedSettings.spec.ts
@@ -197,14 +197,14 @@ describe("versionedSettings", () => {
it("should handle versioned boolean values", () => {
const versionedSettings: VersionedSettings = {
"3.36.0": {
- supportsNativeTools: true,
+ supportsReasoningEffort: true,
},
}
const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
expect(resolved).toEqual({
- supportsNativeTools: true,
+ supportsReasoningEffort: true,
})
})
diff --git a/src/api/providers/fetchers/chutes.ts b/src/api/providers/fetchers/chutes.ts
index 247d8f3c55..d79a2c80b0 100644
--- a/src/api/providers/fetchers/chutes.ts
+++ b/src/api/providers/fetchers/chutes.ts
@@ -57,8 +57,10 @@ export async function getChutesModels(apiKey?: string): Promise
-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.ts b/src/api/providers/gemini.ts
index dada9db14c..823ed0ac8b 100644
--- a/src/api/providers/gemini.ts
+++ b/src/api/providers/gemini.ts
@@ -93,8 +93,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
// Gemini 3 validates thought signatures for tool/function calling steps.
// We must round-trip the signature when tools are in use, even if the user chose
// a minimal thinking level (or thinkingConfig is otherwise absent).
- const usingNativeTools = Boolean(metadata?.tools && metadata.tools.length > 0)
- const includeThoughtSignatures = Boolean(thinkingConfig) || usingNativeTools
+ const includeThoughtSignatures = Boolean(thinkingConfig) || Boolean(metadata?.tools?.length)
// The message list can include provider-specific meta entries such as
// `{ type: "reasoning", ... }` that are intended only for providers like
@@ -129,29 +128,19 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
.map((message) => convertAnthropicMessageToGemini(message, { includeThoughtSignatures, toolIdToName }))
.flat()
- const tools: GenerateContentConfig["tools"] = []
-
- // Google built-in tools (Grounding, URL Context) are currently mutually exclusive
- // with function declarations in the Gemini API. If native function calling is
- // used (Agent tools), we must prioritize it and skip built-in tools to avoid
- // "Tool use with function calling is unsupported" (HTTP 400) errors.
- if (metadata?.tools && metadata.tools.length > 0) {
- tools.push({
- functionDeclarations: metadata.tools.map((tool) => ({
+ // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS).
+ // Google built-in tools (Grounding, URL Context) are mutually exclusive
+ // with function declarations in the Gemini API, so we always use
+ // function declarations when tools are provided.
+ const tools: GenerateContentConfig["tools"] = [
+ {
+ functionDeclarations: (metadata?.tools ?? []).map((tool) => ({
name: (tool as any).function.name,
description: (tool as any).function.description,
parametersJsonSchema: (tool as any).function.parameters,
})),
- })
- } else {
- if (this.options.enableUrlContext) {
- tools.push({ urlContext: {} })
- }
-
- if (this.options.enableGrounding) {
- tools.push({ googleSearch: {} })
- }
- }
+ },
+ ]
// Determine temperature respecting model capabilities and defaults:
// - If supportsTemperature is explicitly false, ignore user overrides
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/index.ts b/src/api/providers/index.ts
index 1e0ae50c9d..cf49f75f18 100644
--- a/src/api/providers/index.ts
+++ b/src/api/providers/index.ts
@@ -3,7 +3,6 @@ export { AnthropicHandler } from "./anthropic"
export { AwsBedrockHandler } from "./bedrock"
export { CerebrasHandler } from "./cerebras"
export { ChutesHandler } from "./chutes"
-export { ClaudeCodeHandler } from "./claude-code"
export { DeepSeekHandler } from "./deepseek"
export { DoubaoHandler } from "./doubao"
export { MoonshotHandler } from "./moonshot"
@@ -18,6 +17,8 @@ export { MistralHandler } from "./mistral"
export { OpenAiCodexHandler } from "./openai-codex"
export { OpenAiNativeHandler } from "./openai-native"
export { OpenAiHandler } from "./openai"
+export { OpenAICompatibleHandler } from "./openai-compatible"
+export type { OpenAICompatibleConfig } from "./openai-compatible"
export { OpenRouterHandler } from "./openrouter"
export { QwenCodeHandler } from "./qwen-code"
export { RequestyHandler } from "./requesty"
diff --git a/src/api/providers/lite-llm.ts b/src/api/providers/lite-llm.ts
index fbafc9410f..cf8d16a112 100644
--- a/src/api/providers/lite-llm.ts
+++ b/src/api/providers/lite-llm.ts
@@ -1,7 +1,7 @@
import OpenAI from "openai"
import { Anthropic } from "@anthropic-ai/sdk" // Keep for type usage only
-import { litellmDefaultModelId, litellmDefaultModelInfo, TOOL_PROTOCOL } from "@roo-code/types"
+import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types"
import { calculateApiCostOpenAI } from "../../shared/cost"
@@ -9,7 +9,7 @@ import { ApiHandlerOptions } from "../../shared/api"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { convertToOpenAiMessages } from "../transform/openai-format"
-import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
+import { sanitizeOpenAiCallId } from "../../utils/tool-id"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { RouterProvider } from "./router-provider"
@@ -46,15 +46,21 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
private isGeminiModel(modelId: string): boolean {
// Match various Gemini model patterns:
// - gemini-3-pro, gemini-3-flash, gemini-3-*
+ // - gemini 3 pro, Gemini 3 Pro (space-separated, case-insensitive)
// - gemini/gemini-3-*, google/gemini-3-*
// - vertex_ai/gemini-3-*, vertex/gemini-3-*
// Also match Gemini 2.5+ models which use similar validation
const lowerModelId = modelId.toLowerCase()
return (
+ // Match hyphenated versions: gemini-3, gemini-2.5
lowerModelId.includes("gemini-3") ||
lowerModelId.includes("gemini-2.5") ||
+ // Match space-separated versions: "gemini 3", "gemini 2.5"
+ // This handles model names like "Gemini 3 Pro" from LiteLLM model groups
+ lowerModelId.includes("gemini 3") ||
+ lowerModelId.includes("gemini 2.5") ||
// Also match provider-prefixed versions
- /\b(gemini|google|vertex_ai|vertex)\/gemini-(3|2\.5)/i.test(modelId)
+ /\b(gemini|google|vertex_ai|vertex)\/gemini[-\s](3|2\.5)/i.test(modelId)
)
}
@@ -110,7 +116,9 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
): ApiStream {
const { id: modelId, info } = await this.fetchModel()
- const openAiMessages = convertToOpenAiMessages(messages)
+ const openAiMessages = convertToOpenAiMessages(messages, {
+ normalizeToolCallId: sanitizeOpenAiCallId,
+ })
// Prepare messages with cache control if enabled and supported
let systemMessage: OpenAI.Chat.ChatCompletionMessageParam
@@ -181,14 +189,6 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
// Check if this is a GPT-5 model that requires max_completion_tokens instead of max_tokens
const isGPT5Model = this.isGpt5(modelId)
- // Resolve tool protocol - use metadata's locked protocol if provided, otherwise resolve from options
- const toolProtocol = resolveToolProtocol(this.options, info, metadata?.toolProtocol)
- const isNativeProtocol = toolProtocol === TOOL_PROTOCOL.NATIVE
-
- // Check if model supports native tools and tools are provided with native protocol
- const supportsNativeTools = info.supportsNativeTools ?? false
- const useNativeTools = supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && isNativeProtocol
-
// For Gemini models with native protocol: inject fake reasoning.encrypted block for tool calls
// This is required when switching from other models to Gemini to satisfy API validation.
// Gemini 3 models validate thought signatures for function calls, and when conversation
@@ -196,7 +196,7 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
// signatures. The "skip_thought_signature_validator" value bypasses this validation.
const isGemini = this.isGeminiModel(modelId)
let processedMessages = enhancedMessages
- if (isNativeProtocol && isGemini) {
+ if (isGemini) {
processedMessages = this.injectThoughtSignatureForGemini(enhancedMessages)
}
@@ -207,8 +207,8 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
stream_options: {
include_usage: true,
},
- ...(useNativeTools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(useNativeTools && metadata.tool_choice && { tool_choice: metadata.tool_choice }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
}
// GPT-5 models require max_completion_tokens instead of the deprecated max_tokens parameter
diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts
index 102c108dce..a771394c53 100644
--- a/src/api/providers/lm-studio.ts
+++ b/src/api/providers/lm-studio.ts
@@ -7,7 +7,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATU
import type { ApiHandlerOptions } from "../../shared/api"
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
-import { XmlMatcher } from "../../utils/xml-matcher"
+import { TagMatcher } from "../../utils/tag-matcher"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@@ -47,9 +47,6 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
...convertToOpenAiMessages(messages),
]
- // LM Studio always supports native tools (https://lmstudio.ai/docs/developer/core/tools)
- const useNativeTools = metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml"
-
// -------------------------
// Track token usage
// -------------------------
@@ -91,9 +88,9 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
messages: openAiMessages,
temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE,
stream: true,
- ...(useNativeTools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(useNativeTools && metadata.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(useNativeTools && { parallel_tool_calls: metadata?.parallelToolCalls ?? false }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) {
@@ -107,7 +104,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
throw handleOpenAIError(error, this.providerName)
}
- const matcher = new XmlMatcher(
+ const matcher = new TagMatcher(
"think",
(chunk) =>
({
diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts
index a7cea478ed..bfcf4e3be4 100644
--- a/src/api/providers/minimax.ts
+++ b/src/api/providers/minimax.ts
@@ -109,20 +109,8 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand
system: systemBlocks,
messages: supportsPromptCache ? this.addCacheControl(processedMessages, cacheControl) : processedMessages,
stream: true,
- }
-
- // Add tool support if provided - convert OpenAI format to Anthropic format
- // Only include native tools when toolProtocol is not 'xml'
- if (metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml") {
- requestParams.tools = convertOpenAIToolsToAnthropic(metadata.tools)
-
- // Only add tool_choice if tools are present
- if (metadata?.tool_choice) {
- const convertedChoice = convertOpenAIToolChoice(metadata.tool_choice)
- if (convertedChoice) {
- requestParams.tool_choice = convertedChoice
- }
- }
+ tools: convertOpenAIToolsToAnthropic(metadata?.tools ?? []),
+ tool_choice: convertOpenAIToolChoice(metadata?.tool_choice),
}
stream = await this.client.messages.create(requestParams)
diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts
index 95739cdcf7..e0e19298f4 100644
--- a/src/api/providers/mistral.ts
+++ b/src/api/providers/mistral.ts
@@ -94,13 +94,9 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand
temperature,
}
- // Add tools if provided and toolProtocol is not 'xml' and model supports native tools
- const supportsNativeTools = info.supportsNativeTools ?? false
- if (metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml" && supportsNativeTools) {
- requestOptions.tools = this.convertToolsForMistral(metadata.tools)
- // Always use "any" to require tool use
- requestOptions.toolChoice = "any"
- }
+ requestOptions.tools = this.convertToolsForMistral(metadata?.tools ?? [])
+ // Always use "any" to require tool use
+ requestOptions.toolChoice = "any"
// Temporary debug log for QA
// console.log("[MISTRAL DEBUG] Raw API request body:", requestOptions)
diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts
index d29a10a3b3..f7a849cc02 100644
--- a/src/api/providers/moonshot.ts
+++ b/src/api/providers/moonshot.ts
@@ -1,4 +1,3 @@
-import OpenAI from "openai"
import { moonshotModels, moonshotDefaultModelId, type ModelInfo } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
@@ -6,18 +5,25 @@ import type { ApiHandlerOptions } from "../../shared/api"
import type { ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
-import { OpenAiHandler } from "./openai"
+import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible"
-export class MoonshotHandler extends OpenAiHandler {
+export class MoonshotHandler extends OpenAICompatibleHandler {
constructor(options: ApiHandlerOptions) {
- super({
- ...options,
- openAiApiKey: options.moonshotApiKey ?? "not-provided",
- openAiModelId: options.apiModelId ?? moonshotDefaultModelId,
- openAiBaseUrl: options.moonshotBaseUrl ?? "https://api.moonshot.ai/v1",
- openAiStreamingEnabled: true,
- includeMaxTokens: true,
- })
+ const modelId = options.apiModelId ?? moonshotDefaultModelId
+ const modelInfo =
+ moonshotModels[modelId as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId]
+
+ const config: OpenAICompatibleConfig = {
+ providerName: "moonshot",
+ baseURL: options.moonshotBaseUrl ?? "https://api.moonshot.ai/v1",
+ apiKey: options.moonshotApiKey ?? "not-provided",
+ modelId,
+ modelInfo,
+ modelMaxTokens: options.modelMaxTokens ?? undefined,
+ temperature: options.modelTemperature ?? undefined,
+ }
+
+ super(options, config)
}
override getModel() {
@@ -27,25 +33,38 @@ export class MoonshotHandler extends OpenAiHandler {
return { id, info, ...params }
}
- // Override to handle Moonshot's usage metrics, including caching.
- protected override processUsageMetrics(usage: any): ApiStreamUsageChunk {
+ /**
+ * Override to handle Moonshot's usage metrics, including caching.
+ * Moonshot returns cached_tokens in a different location than standard OpenAI.
+ */
+ protected override processUsageMetrics(usage: {
+ inputTokens?: number
+ outputTokens?: number
+ details?: {
+ cachedInputTokens?: number
+ reasoningTokens?: number
+ }
+ raw?: Record
+ }): ApiStreamUsageChunk {
+ // Moonshot uses cached_tokens at the top level of raw usage data
+ const rawUsage = usage.raw as { cached_tokens?: number } | undefined
+
return {
type: "usage",
- inputTokens: usage?.prompt_tokens || 0,
- outputTokens: usage?.completion_tokens || 0,
+ inputTokens: usage.inputTokens || 0,
+ outputTokens: usage.outputTokens || 0,
cacheWriteTokens: 0,
- cacheReadTokens: usage?.cached_tokens,
+ cacheReadTokens: rawUsage?.cached_tokens ?? usage.details?.cachedInputTokens,
}
}
- // Override to always include max_tokens for Moonshot (not max_completion_tokens)
- protected override addMaxTokensIfNeeded(
- requestOptions:
- | OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
- | OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming,
- modelInfo: ModelInfo,
- ): void {
- // Moonshot uses max_tokens instead of max_completion_tokens
- requestOptions.max_tokens = this.options.modelMaxTokens || modelInfo.maxTokens
+ /**
+ * Override to always include max_tokens for Moonshot (not max_completion_tokens).
+ * Moonshot requires max_tokens parameter to be sent.
+ */
+ protected override getMaxOutputTokens(): number | undefined {
+ const modelInfo = this.config.modelInfo
+ // Moonshot always requires max_tokens
+ return this.options.modelMaxTokens || modelInfo.maxTokens || undefined
}
}
diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts
index f3271d6555..99c1dc03cf 100644
--- a/src/api/providers/native-ollama.ts
+++ b/src/api/providers/native-ollama.ts
@@ -6,7 +6,7 @@ import { ApiStream } from "../transform/stream"
import { BaseProvider } from "./base-provider"
import type { ApiHandlerOptions } from "../../shared/api"
import { getOllamaModels } from "./fetchers/ollama"
-import { XmlMatcher } from "../../utils/xml-matcher"
+import { TagMatcher } from "../../utils/tag-matcher"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
interface OllamaChatOptions {
@@ -206,7 +206,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
const client = this.ensureClient()
- const { id: modelId, info: modelInfo } = await this.fetchModel()
+ const { id: modelId } = await this.fetchModel()
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
const ollamaMessages: Message[] = [
@@ -214,7 +214,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
...convertToOllamaMessages(messages),
]
- const matcher = new XmlMatcher(
+ const matcher = new TagMatcher(
"think",
(chunk) =>
({
@@ -223,11 +223,6 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
}) as const,
)
- // Check if we should use native tool calling
- const supportsNativeTools = modelInfo.supportsNativeTools ?? false
- const useNativeTools =
- supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml"
-
try {
// Build options object conditionally
const chatOptions: OllamaChatOptions = {
@@ -245,8 +240,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
messages: ollamaMessages,
stream: true,
options: chatOptions,
- // Native tool calling support
- ...(useNativeTools && { tools: this.convertToolsToOllama(metadata.tools) }),
+ tools: this.convertToolsToOllama(metadata?.tools),
})
let totalInputTokens = 0
diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts
index 1600381f59..d64780c555 100644
--- a/src/api/providers/openai-codex.ts
+++ b/src/api/providers/openai-codex.ts
@@ -306,28 +306,22 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
},
}
: {}),
- ...(metadata?.tools && {
- tools: metadata.tools
- .filter((tool) => tool.type === "function")
- .map((tool) => {
- const isMcp = isMcpTool(tool.function.name)
- return {
- type: "function",
- name: tool.function.name,
- description: tool.function.description,
- parameters: isMcp
- ? ensureAdditionalPropertiesFalse(tool.function.parameters)
- : ensureAllRequired(tool.function.parameters),
- strict: !isMcp,
- }
- }),
- }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
- }
-
- // For native tool protocol, control parallel tool calls
- if (metadata?.toolProtocol === "native") {
- body.parallel_tool_calls = metadata.parallelToolCalls ?? false
+ tools: (metadata?.tools ?? [])
+ .filter((tool) => tool.type === "function")
+ .map((tool) => {
+ const isMcp = isMcpTool(tool.function.name)
+ return {
+ type: "function",
+ name: tool.function.name,
+ description: tool.function.description,
+ parameters: isMcp
+ ? ensureAdditionalPropertiesFalse(tool.function.parameters)
+ : ensureAllRequired(tool.function.parameters),
+ strict: !isMcp,
+ }
+ }),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
return body
@@ -914,31 +908,29 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
}
}
- if (item.type === "text" && item.text) {
- yield { type: "text", text: item.text }
- } else if (item.type === "reasoning" && item.text) {
- yield { type: "reasoning", text: item.text }
- } else if (item.type === "message" && Array.isArray(item.content)) {
- for (const content of item.content) {
- if ((content?.type === "text" || content?.type === "output_text") && content?.text) {
- yield { type: "text", text: content.text }
- }
- }
- } else if (
- (item.type === "function_call" || item.type === "tool_call") &&
- event.type === "response.output_item.done"
- ) {
- const callId = item.call_id || item.tool_call_id || item.id
- if (callId) {
- const args = item.arguments || item.function?.arguments || item.function_arguments
- yield {
- type: "tool_call",
- id: callId,
- name: item.name || item.function?.name || item.function_name || "",
- arguments: typeof args === "string" ? args : "{}",
+ // For "added" events, yield text/reasoning content (streaming path)
+ // For "done" events, do NOT yield text/reasoning - it's already been streamed via deltas
+ // and would cause double-emission (A, B, C, ABC).
+ if (event.type === "response.output_item.added") {
+ if (item.type === "text" && item.text) {
+ yield { type: "text", text: item.text }
+ } else if (item.type === "reasoning" && item.text) {
+ yield { type: "reasoning", text: item.text }
+ } else if (item.type === "message" && Array.isArray(item.content)) {
+ for (const content of item.content) {
+ if ((content?.type === "text" || content?.type === "output_text") && content?.text) {
+ yield { type: "text", text: content.text }
+ }
}
}
}
+
+ // Note: We intentionally do NOT emit tool_call from response.output_item.done
+ // for function_call/tool_call items. The streaming path handles tool calls via:
+ // 1. tool_call_partial events during argument deltas
+ // 2. NativeToolCallParser.finalizeRawChunks() at stream end emitting tool_call_end
+ // 3. NativeToolCallParser.finalizeStreamingToolCall() creating the final ToolUse
+ // Emitting tool_call here would cause duplicate tool rendering.
}
return
}
diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts
new file mode 100644
index 0000000000..240de747be
--- /dev/null
+++ b/src/api/providers/openai-compatible.ts
@@ -0,0 +1,189 @@
+/**
+ * OpenAI-compatible provider base class using Vercel AI SDK.
+ * This provides a parallel implementation to OpenAiHandler using @ai-sdk/openai-compatible.
+ */
+
+import { Anthropic } from "@anthropic-ai/sdk"
+import OpenAI from "openai"
+import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
+import { streamText, generateText, LanguageModel, ToolSet } from "ai"
+
+import 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 { DEFAULT_HEADERS } from "./constants"
+import { BaseProvider } from "./base-provider"
+import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
+
+/**
+ * Configuration options for creating an OpenAI-compatible provider.
+ */
+export interface OpenAICompatibleConfig {
+ /** Provider name for identification */
+ providerName: string
+ /** Base URL for the API endpoint */
+ baseURL: string
+ /** API key for authentication */
+ apiKey: string
+ /** Model ID to use */
+ modelId: string
+ /** Model information */
+ modelInfo: ModelInfo
+ /** Optional custom headers */
+ headers?: Record
+ /** Whether to include max_tokens in requests (default: false uses max_completion_tokens) */
+ useMaxTokens?: boolean
+ /** User-configured max tokens override */
+ modelMaxTokens?: number
+ /** Temperature setting */
+ temperature?: number
+}
+
+/**
+ * Base class for OpenAI-compatible API providers using Vercel AI SDK.
+ * Extends BaseProvider and implements SingleCompletionHandler.
+ */
+export abstract class OpenAICompatibleHandler extends BaseProvider implements SingleCompletionHandler {
+ protected options: ApiHandlerOptions
+ protected config: OpenAICompatibleConfig
+ protected provider: ReturnType
+
+ constructor(options: ApiHandlerOptions, config: OpenAICompatibleConfig) {
+ super()
+ this.options = options
+ this.config = config
+
+ // Create the OpenAI-compatible provider using AI SDK
+ this.provider = createOpenAICompatible({
+ name: config.providerName,
+ baseURL: config.baseURL,
+ apiKey: config.apiKey,
+ headers: {
+ ...DEFAULT_HEADERS,
+ ...(config.headers || {}),
+ },
+ })
+ }
+
+ /**
+ * Get the language model for the configured model ID.
+ */
+ protected getLanguageModel(): LanguageModel {
+ return this.provider(this.config.modelId)
+ }
+
+ /**
+ * Get the model information. Must be implemented by subclasses.
+ */
+ abstract override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number }
+
+ /**
+ * Process usage metrics from the AI SDK response.
+ * Can be overridden by subclasses to handle provider-specific usage formats.
+ */
+ protected processUsageMetrics(usage: {
+ inputTokens?: number
+ outputTokens?: number
+ details?: {
+ cachedInputTokens?: number
+ reasoningTokens?: number
+ }
+ raw?: Record
+ }): ApiStreamUsageChunk {
+ return {
+ type: "usage",
+ inputTokens: usage.inputTokens || 0,
+ outputTokens: usage.outputTokens || 0,
+ cacheReadTokens: usage.details?.cachedInputTokens,
+ reasoningTokens: usage.details?.reasoningTokens,
+ }
+ }
+
+ /**
+ * Get the max tokens parameter to include in the request.
+ */
+ protected getMaxOutputTokens(): number | undefined {
+ const modelInfo = this.config.modelInfo
+ const maxTokens = this.config.modelMaxTokens || modelInfo.maxTokens
+
+ return maxTokens ?? undefined
+ }
+
+ /**
+ * Create a message stream using the AI SDK.
+ */
+ override async *createMessage(
+ systemPrompt: string,
+ messages: Anthropic.Messages.MessageParam[],
+ metadata?: ApiHandlerCreateMessageMetadata,
+ ): ApiStream {
+ const model = 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: model.temperature ?? this.config.temperature ?? 0,
+ 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
+ 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)
+ }
+ } catch (error) {
+ // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
+ throw handleAiSdkError(error, this.config.providerName)
+ }
+ }
+
+ /**
+ * Complete a prompt using the AI SDK generateText.
+ */
+ async completePrompt(prompt: string): Promise {
+ const languageModel = this.getLanguageModel()
+
+ const { text } = await generateText({
+ model: languageModel,
+ prompt,
+ maxOutputTokens: this.getMaxOutputTokens(),
+ temperature: this.config.temperature ?? 0,
+ })
+
+ return text
+ }
+}
diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts
index 61db7dd20d..abf1a562c7 100644
--- a/src/api/providers/openai-native.ts
+++ b/src/api/providers/openai-native.ts
@@ -360,34 +360,25 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
// Enable extended prompt cache retention for models that support it.
// This uses the OpenAI Responses API `prompt_cache_retention` parameter.
...(promptCacheRetention ? { prompt_cache_retention: promptCacheRetention } : {}),
- ...(metadata?.tools && {
- tools: metadata.tools
- .filter((tool) => tool.type === "function")
- .map((tool) => {
- // MCP tools use the 'mcp--' prefix - disable strict mode for them
- // to preserve optional parameters from the MCP server schema
- // But we still need to add additionalProperties: false for OpenAI Responses API
- const isMcp = isMcpTool(tool.function.name)
- return {
- type: "function",
- name: tool.function.name,
- description: tool.function.description,
- parameters: isMcp
- ? ensureAdditionalPropertiesFalse(tool.function.parameters)
- : ensureAllRequired(tool.function.parameters),
- strict: !isMcp,
- }
- }),
- }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
- }
-
- // For native tool protocol, control parallel tool calls based on the metadata flag.
- // When parallelToolCalls is true, allow parallel tool calls (OpenAI's parallel_tool_calls=true).
- // When false (default), explicitly disable parallel tool calls (false).
- // For XML or when protocol is unset, omit the field entirely so the API default applies.
- if (metadata?.toolProtocol === "native") {
- body.parallel_tool_calls = metadata.parallelToolCalls ?? false
+ tools: (metadata?.tools ?? [])
+ .filter((tool) => tool.type === "function")
+ .map((tool) => {
+ // MCP tools use the 'mcp--' prefix - disable strict mode for them
+ // to preserve optional parameters from the MCP server schema
+ // But we still need to add additionalProperties: false for OpenAI Responses API
+ const isMcp = isMcpTool(tool.function.name)
+ return {
+ type: "function",
+ name: tool.function.name,
+ description: tool.function.description,
+ parameters: isMcp
+ ? ensureAdditionalPropertiesFalse(tool.function.parameters)
+ : ensureAllRequired(tool.function.parameters),
+ strict: !isMcp,
+ }
+ }),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
// Include text.verbosity only when the model explicitly supports it
@@ -1232,34 +1223,30 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
}
- if (item.type === "text" && item.text) {
- yield { type: "text", text: item.text }
- } else if (item.type === "reasoning" && item.text) {
- yield { type: "reasoning", text: item.text }
- } else if (item.type === "message" && Array.isArray(item.content)) {
- for (const content of item.content) {
- // Some implementations send 'text'; others send 'output_text'
- if ((content?.type === "text" || content?.type === "output_text") && content?.text) {
- yield { type: "text", text: content.text }
- }
- }
- } else if (
- (item.type === "function_call" || item.type === "tool_call") &&
- event.type === "response.output_item.done" // Only handle done events for tool calls to ensure arguments are complete
- ) {
- // Handle complete tool/function call item
- // Emit as tool_call for backward compatibility with non-streaming tool handling
- const callId = item.call_id || item.tool_call_id || item.id
- if (callId) {
- const args = item.arguments || item.function?.arguments || item.function_arguments
- yield {
- type: "tool_call",
- id: callId,
- name: item.name || item.function?.name || item.function_name || "",
- arguments: typeof args === "string" ? args : "{}",
+ // For "added" events, yield text/reasoning content (streaming path)
+ // For "done" events, do NOT yield text/reasoning - it's already been streamed via deltas
+ // and would cause double-emission (A, B, C, ABC).
+ if (event.type === "response.output_item.added") {
+ if (item.type === "text" && item.text) {
+ yield { type: "text", text: item.text }
+ } else if (item.type === "reasoning" && item.text) {
+ yield { type: "reasoning", text: item.text }
+ } else if (item.type === "message" && Array.isArray(item.content)) {
+ for (const content of item.content) {
+ // Some implementations send 'text'; others send 'output_text'
+ if ((content?.type === "text" || content?.type === "output_text") && content?.text) {
+ yield { type: "text", text: content.text }
+ }
}
}
}
+
+ // Note: We intentionally do NOT emit tool_call from response.output_item.done
+ // for function_call/tool_call items. The streaming path handles tool calls via:
+ // 1. tool_call_partial events during argument deltas
+ // 2. NativeToolCallParser.finalizeRawChunks() at stream end emitting tool_call_end
+ // 3. NativeToolCallParser.finalizeStreamingToolCall() creating the final ToolUse
+ // Emitting tool_call here would cause duplicate tool rendering.
}
return
}
diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts
index 9d632fbdf4..87589b9396 100644
--- a/src/api/providers/openai.ts
+++ b/src/api/providers/openai.ts
@@ -6,14 +6,13 @@ import {
type ModelInfo,
azureOpenAiDefaultApiVersion,
openAiModelInfoSaneDefaults,
- NATIVE_TOOL_DEFAULTS,
DEEP_SEEK_DEFAULT_TEMPERATURE,
OPENAI_AZURE_AI_INFERENCE_PATH,
} from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
-import { XmlMatcher } from "../../utils/xml-matcher"
+import { TagMatcher } from "../../utils/tag-matcher"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
@@ -160,12 +159,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
stream: true as const,
...(isGrokXAI ? {} : { stream_options: { include_usage: true } }),
...(reasoning && reasoning),
- ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(metadata?.toolProtocol === "native" &&
- metadata.parallelToolCalls === true && {
- parallel_tool_calls: true,
- }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
// Add max_tokens if needed
@@ -181,7 +177,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
throw handleOpenAIError(error, this.providerName)
}
- const matcher = new XmlMatcher(
+ const matcher = new TagMatcher(
"think",
(chunk) =>
({
@@ -230,12 +226,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
messages: deepseekReasoner
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
: [systemMessage, ...convertToOpenAiMessages(messages)],
- ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(metadata?.toolProtocol === "native" &&
- metadata.parallelToolCalls === true && {
- parallel_tool_calls: true,
- }),
+ // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS)
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
// Add max_tokens if needed
@@ -287,13 +281,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
override getModel() {
const id = this.options.openAiModelId ?? ""
- // Ensure OpenAI-compatible models default to supporting native tool calling.
- // This is required for [`Task.attemptApiRequest()`](src/core/task/Task.ts:3817) to
- // include tool definitions in the request.
- const info: ModelInfo = {
- ...NATIVE_TOOL_DEFAULTS,
- ...(this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults),
- }
+ const info: ModelInfo = this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
return { id, info, ...params }
}
@@ -357,12 +345,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
...(isGrokXAI ? {} : { stream_options: { include_usage: true } }),
reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined,
temperature: undefined,
- ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(metadata?.toolProtocol === "native" &&
- metadata.parallelToolCalls === true && {
- parallel_tool_calls: true,
- }),
+ // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS)
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
// O3 family models do not support the deprecated max_tokens parameter
@@ -393,12 +379,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
],
reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined,
temperature: undefined,
- ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(metadata?.toolProtocol === "native" &&
- metadata.parallelToolCalls === true && {
- parallel_tool_calls: true,
- }),
+ // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS)
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
// O3 family models do not support the deprecated max_tokens parameter
diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts
index 902b2f646b..7fcc24b15f 100644
--- a/src/api/providers/openrouter.ts
+++ b/src/api/providers/openrouter.ts
@@ -23,8 +23,6 @@ import {
consolidateReasoningDetails,
} from "../transform/openai-format"
import { normalizeMistralToolCallId } from "../transform/mistral-format"
-import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
-import { TOOL_PROTOCOL } from "@roo-code/types"
import { ApiStreamChunk } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
import { addCacheBreakpoints as addAnthropicCacheBreakpoints } from "../transform/caching/anthropic"
@@ -249,10 +247,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
- // Process reasoning_details when switching models to Gemini for native tool call compatibility
- // IMPORTANT: Use metadata.toolProtocol if provided (task's locked protocol) for consistency
- const toolProtocol = resolveToolProtocol(this.options, model.info, metadata?.toolProtocol)
- const isNativeProtocol = toolProtocol === TOOL_PROTOCOL.NATIVE
+ // Process reasoning_details when switching models to Gemini.
const isGemini = modelId.startsWith("google/gemini")
// For Gemini models with native protocol:
@@ -267,7 +262,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
// - Set `data` to "skip_thought_signature_validator" to bypass signature validation
// - Set `index` to 0
// See: https://github.com/cline/cline/issues/8214
- if (isNativeProtocol && isGemini) {
+ if (isGemini) {
// Step 1: Sanitize messages - filter out tool calls with missing/mismatched reasoning_details
openAiMessages = sanitizeGeminiMessages(openAiMessages, modelId)
@@ -332,8 +327,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
},
}),
...(reasoning && { reasoning }),
- ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
}
// Add Anthropic beta header for fine-grained tool streaming when using Anthropic models
diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts
index 8f26273eba..18d09a59f3 100644
--- a/src/api/providers/qwen-code.ts
+++ b/src/api/providers/qwen-code.ts
@@ -212,11 +212,6 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan
const client = this.ensureClient()
const model = this.getModel()
- // Check if model supports native tools and tools are provided with native protocol
- const supportsNativeTools = model.info.supportsNativeTools ?? false
- const useNativeTools =
- supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml"
-
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
content: systemPrompt,
@@ -231,9 +226,9 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan
stream: true,
stream_options: { include_usage: true },
max_completion_tokens: model.info.maxTokens,
- ...(useNativeTools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(useNativeTools && metadata.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(useNativeTools && { parallel_tool_calls: metadata?.parallelToolCalls ?? false }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
const stream = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions))
diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts
index eb05bfd0a1..c3b5accbc3 100644
--- a/src/api/providers/requesty.ts
+++ b/src/api/providers/requesty.ts
@@ -1,17 +1,9 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
-import {
- type ModelInfo,
- type ModelRecord,
- requestyDefaultModelId,
- requestyDefaultModelInfo,
- TOOL_PROTOCOL,
- NATIVE_TOOL_DEFAULTS,
-} from "@roo-code/types"
+import { type ModelInfo, type ModelRecord, requestyDefaultModelId, requestyDefaultModelInfo } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
-import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
import { calculateApiCostOpenAI } from "../../shared/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
@@ -87,10 +79,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
override getModel() {
const id = this.options.requestyModelId ?? requestyDefaultModelId
const cachedInfo = this.models[id] ?? requestyDefaultModelInfo
-
- // Merge native tool defaults for cached models that may lack these fields
- // The order ensures that cached values (if present) override the defaults
- let info: ModelInfo = { ...NATIVE_TOOL_DEFAULTS, ...cachedInfo }
+ let info: ModelInfo = cachedInfo
// Apply tool preferences for models accessed through routers (OpenAI, Gemini)
info = applyRouterToolPreferences(id, info)
@@ -149,11 +138,6 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
? (reasoning_effort as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming["reasoning_effort"])
: undefined
- // Check if native tool protocol is enabled
- // IMPORTANT: Use metadata.toolProtocol if provided (task's locked protocol) for consistency
- const toolProtocol = resolveToolProtocol(this.options, info, metadata?.toolProtocol)
- const useNativeTools = toolProtocol === TOOL_PROTOCOL.NATIVE
-
const completionParams: RequestyChatCompletionParamsStreaming = {
messages: openAiMessages,
model,
@@ -164,8 +148,8 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
stream: true,
stream_options: { include_usage: true },
requesty: { trace_id: metadata?.taskId, extra: { mode: metadata?.mode } },
- ...(useNativeTools && metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(useNativeTools && metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
}
let stream
diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts
index 752ad938ef..b455a1885e 100644
--- a/src/api/providers/roo.ts
+++ b/src/api/providers/roo.ts
@@ -106,8 +106,8 @@ export class RooHandler extends BaseOpenAiCompatibleProvider {
stream: true,
stream_options: { include_usage: true },
...(reasoning && { reasoning }),
- ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
}
try {
@@ -375,7 +375,6 @@ export class RooHandler extends BaseOpenAiCompatibleProvider {
supportsImages: false,
supportsReasoningEffort: false,
supportsPromptCache: true,
- supportsNativeTools: false,
inputPrice: 0,
outputPrice: 0,
isFree: false,
diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts
index 4721f21666..09b102d5b2 100644
--- a/src/api/providers/router-provider.ts
+++ b/src/api/providers/router-provider.ts
@@ -1,6 +1,6 @@
import OpenAI from "openai"
-import { type ModelInfo, type ModelRecord, NATIVE_TOOL_DEFAULTS } from "@roo-code/types"
+import { type ModelInfo, type ModelRecord } from "@roo-code/types"
import { ApiHandlerOptions, RouterName } from "../../shared/api"
@@ -64,9 +64,8 @@ export abstract class RouterProvider extends BaseProvider {
const id = this.modelId ?? this.defaultModelId
// First check instance models (populated by fetchModel)
- // Merge native tool defaults for cached models that may lack these fields
if (this.models[id]) {
- return { id, info: { ...NATIVE_TOOL_DEFAULTS, ...this.models[id] } }
+ return { id, info: this.models[id] }
}
// Fall back to global cache (synchronous disk/memory cache)
@@ -75,7 +74,7 @@ export abstract class RouterProvider extends BaseProvider {
if (cachedModels?.[id]) {
// Also populate instance models for future calls
this.models = cachedModels
- return { id, info: { ...NATIVE_TOOL_DEFAULTS, ...cachedModels[id] } }
+ return { id, info: cachedModels[id] }
}
// Last resort: return default model
diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts
index 667dcc6083..76dd60d976 100644
--- a/src/api/providers/unbound.ts
+++ b/src/api/providers/unbound.ts
@@ -108,11 +108,6 @@ export class UnboundHandler extends RouterProvider implements SingleCompletionHa
maxTokens = info.maxTokens ?? undefined
}
- // Check if model supports native tools and tools are provided with native protocol
- const supportsNativeTools = info.supportsNativeTools ?? false
- const useNativeTools =
- supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml"
-
const requestOptions: UnboundChatCompletionCreateParamsStreaming = {
model: modelId.split("/")[1],
max_tokens: maxTokens,
@@ -124,9 +119,9 @@ export class UnboundHandler extends RouterProvider implements SingleCompletionHa
taskId: metadata?.taskId,
mode: metadata?.mode,
},
- ...(useNativeTools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(useNativeTools && metadata.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(useNativeTools && { parallel_tool_calls: metadata?.parallelToolCalls ?? false }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
if (this.supportsTemperature(modelId)) {
diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts
index 96863ac1ea..51b0eb5f51 100644
--- a/src/api/providers/vercel-ai-gateway.ts
+++ b/src/api/providers/vercel-ai-gateway.ts
@@ -61,11 +61,9 @@ export class VercelAiGatewayHandler extends RouterProvider implements SingleComp
max_completion_tokens: info.maxTokens,
stream: true,
stream_options: { include_usage: true },
- ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(metadata?.toolProtocol === "native" && {
- parallel_tool_calls: metadata.parallelToolCalls ?? false,
- }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
const completion = await this.client.chat.completions.create(body)
diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts
index 5c598ccd01..8fb564a9d5 100644
--- a/src/api/providers/vscode-lm.ts
+++ b/src/api/providers/vscode-lm.ts
@@ -229,23 +229,29 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
return 0
}
- if (!this.currentRequestCancellation) {
- console.warn("Roo Code : No cancellation token available for token counting")
- return 0
- }
-
// Validate input
if (!text) {
console.debug("Roo Code : Empty text provided for token counting")
return 0
}
+ // Create a temporary cancellation token if we don't have one (e.g., when called outside a request)
+ let cancellationToken: vscode.CancellationToken
+ let tempCancellation: vscode.CancellationTokenSource | null = null
+
+ if (this.currentRequestCancellation) {
+ cancellationToken = this.currentRequestCancellation.token
+ } else {
+ tempCancellation = new vscode.CancellationTokenSource()
+ cancellationToken = tempCancellation.token
+ }
+
try {
// Handle different input types
let tokenCount: number
if (typeof text === "string") {
- tokenCount = await this.client.countTokens(text, this.currentRequestCancellation.token)
+ tokenCount = await this.client.countTokens(text, cancellationToken)
} else if (text instanceof vscode.LanguageModelChatMessage) {
// For chat messages, ensure we have content
if (!text.content || (Array.isArray(text.content) && text.content.length === 0)) {
@@ -253,7 +259,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
return 0
}
const countMessage = extractTextCountFromMessage(text)
- tokenCount = await this.client.countTokens(countMessage, this.currentRequestCancellation.token)
+ tokenCount = await this.client.countTokens(countMessage, cancellationToken)
} else {
console.warn("Roo Code : Invalid input type for token counting")
return 0
@@ -287,6 +293,11 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
}
return 0 // Fallback to prevent stream interruption
+ } finally {
+ // Clean up temporary cancellation token
+ if (tempCancellation) {
+ tempCancellation.dispose()
+ }
}
}
@@ -381,18 +392,11 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
// Accumulate the text and count at the end of the stream to reduce token counting overhead.
let accumulatedText: string = ""
- // Determine if we're using native tool protocol
- const useNativeTools = metadata?.toolProtocol === "native" && metadata?.tools && metadata.tools.length > 0
-
try {
// Create the response stream with required options
const requestOptions: vscode.LanguageModelChatRequestOptions = {
justification: `Roo Code would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`,
- }
-
- // Add tools to request options when using native tool protocol
- if (useNativeTools && metadata?.tools) {
- requestOptions.tools = convertToVsCodeLmTools(metadata.tools)
+ tools: convertToVsCodeLmTools(metadata?.tools ?? []),
}
const response: vscode.LanguageModelChatResponse = await client.sendRequest(
@@ -441,8 +445,8 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
inputSize: JSON.stringify(chunk.input).length,
})
- // Yield native tool_call chunk when using native tool protocol
- if (useNativeTools) {
+ // Yield native tool_call chunk when tools are provided
+ if (metadata?.tools?.length) {
const argumentsString = JSON.stringify(chunk.input)
accumulatedText += argumentsString
yield {
@@ -451,22 +455,6 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
name: chunk.name,
arguments: argumentsString,
}
- } else {
- // Fallback: Convert tool calls to text format for XML tool protocol
- const toolCall = {
- type: "tool_call",
- name: chunk.name,
- arguments: chunk.input,
- callId: chunk.callId,
- }
-
- const toolCallText = JSON.stringify(toolCall)
- accumulatedText += toolCallText
-
- yield {
- type: "text",
- text: toolCallText,
- }
}
} catch (error) {
console.error("Roo Code : Failed to process tool call:", error)
@@ -550,8 +538,6 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
: openAiModelInfoSaneDefaults.contextWindow,
supportsImages: false, // VSCode Language Model API currently doesn't support image inputs
supportsPromptCache: true,
- supportsNativeTools: true, // VSCode Language Model API supports native tool calling
- defaultToolProtocol: "native", // Use native tool protocol by default
inputPrice: 0,
outputPrice: 0,
description: `VSCode Language Model: ${modelId}`,
@@ -571,8 +557,6 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
id: fallbackId,
info: {
...openAiModelInfoSaneDefaults,
- supportsNativeTools: true, // VSCode Language Model API supports native tool calling
- defaultToolProtocol: "native", // Use native tool protocol by default
description: `VSCode Language Model (Fallback): ${fallbackId}`,
},
}
diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts
index a1377a1317..8df9cc66ec 100644
--- a/src/api/providers/xai.ts
+++ b/src/api/providers/xai.ts
@@ -54,11 +54,6 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
): ApiStream {
const { id: modelId, info: modelInfo, reasoning } = this.getModel()
- // Check if model supports native tools and tools are provided with native protocol
- const supportsNativeTools = modelInfo.supportsNativeTools ?? false
- const useNativeTools =
- supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml"
-
// Use the OpenAI-compatible API.
const requestOptions = {
model: modelId,
@@ -71,9 +66,9 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
stream: true as const,
stream_options: { include_usage: true },
...(reasoning && reasoning),
- ...(useNativeTools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(useNativeTools && metadata.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(useNativeTools && { parallel_tool_calls: metadata?.parallelToolCalls ?? false }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
let stream
diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts
index c7bf6d635e..a2e3740c56 100644
--- a/src/api/providers/zai.ts
+++ b/src/api/providers/zai.ts
@@ -101,11 +101,9 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider {
stream_options: { include_usage: true },
// For GLM-4.7: thinking is ON by default, so we explicitly disable when needed
thinking: useReasoning ? { type: "enabled" } : { type: "disabled" },
- ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
- ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
- ...(metadata?.toolProtocol === "native" && {
- parallel_tool_calls: metadata.parallelToolCalls ?? false,
- }),
+ tools: this.convertToolsForOpenAI(metadata?.tools),
+ tool_choice: metadata?.tool_choice,
+ parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
return this.client.chat.completions.create(params)
diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts
new file mode 100644
index 0000000000..bd87fd8eeb
--- /dev/null
+++ b/src/api/transform/__tests__/ai-sdk.spec.ts
@@ -0,0 +1,647 @@
+import { Anthropic } from "@anthropic-ai/sdk"
+import OpenAI from "openai"
+import {
+ convertToAiSdkMessages,
+ convertToolsForAiSdk,
+ processAiSdkStreamPart,
+ mapToolChoice,
+ extractAiSdkErrorMessage,
+ handleAiSdkError,
+} from "../ai-sdk"
+
+vitest.mock("ai", () => ({
+ tool: vitest.fn((t) => t),
+ jsonSchema: vitest.fn((s) => s),
+}))
+
+describe("AI SDK conversion utilities", () => {
+ describe("convertToAiSdkMessages", () => {
+ it("converts simple string messages", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ { role: "user", content: "Hello" },
+ { role: "assistant", content: "Hi there" },
+ ]
+
+ const result = convertToAiSdkMessages(messages)
+
+ expect(result).toHaveLength(2)
+ expect(result[0]).toEqual({ role: "user", content: "Hello" })
+ expect(result[1]).toEqual({ role: "assistant", content: "Hi there" })
+ })
+
+ it("converts user messages with text content blocks", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "user",
+ content: [{ type: "text", text: "Hello world" }],
+ },
+ ]
+
+ const result = convertToAiSdkMessages(messages)
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({
+ role: "user",
+ content: [{ type: "text", text: "Hello world" }],
+ })
+ })
+
+ it("converts user messages with image content", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "What is in this image?" },
+ {
+ type: "image",
+ source: {
+ type: "base64",
+ media_type: "image/png",
+ data: "base64encodeddata",
+ },
+ },
+ ],
+ },
+ ]
+
+ const result = convertToAiSdkMessages(messages)
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({
+ role: "user",
+ content: [
+ { type: "text", text: "What is in this image?" },
+ {
+ type: "image",
+ image: "data:image/png;base64,base64encodeddata",
+ mimeType: "image/png",
+ },
+ ],
+ })
+ })
+
+ it("converts user messages with URL image content", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "What is in this image?" },
+ {
+ type: "image",
+ source: {
+ type: "url",
+ url: "https://example.com/image.png",
+ },
+ } as any,
+ ],
+ },
+ ]
+
+ const result = convertToAiSdkMessages(messages)
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({
+ role: "user",
+ content: [
+ { type: "text", text: "What is in this image?" },
+ {
+ type: "image",
+ image: "https://example.com/image.png",
+ },
+ ],
+ })
+ })
+
+ it("converts tool results into separate tool role messages with resolved tool names", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "tool_use",
+ id: "call_123",
+ name: "read_file",
+ input: { path: "test.ts" },
+ },
+ ],
+ },
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: "call_123",
+ content: "Tool result content",
+ },
+ ],
+ },
+ ]
+
+ const result = convertToAiSdkMessages(messages)
+
+ expect(result).toHaveLength(2)
+ expect(result[0]).toEqual({
+ role: "assistant",
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "call_123",
+ toolName: "read_file",
+ input: { path: "test.ts" },
+ },
+ ],
+ })
+ // Tool results now go to role: "tool" messages per AI SDK v6 schema
+ expect(result[1]).toEqual({
+ role: "tool",
+ content: [
+ {
+ type: "tool-result",
+ toolCallId: "call_123",
+ toolName: "read_file",
+ output: { type: "text", value: "Tool result content" },
+ },
+ ],
+ })
+ })
+
+ it("uses unknown_tool for tool results without matching tool call", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: "call_orphan",
+ content: "Orphan result",
+ },
+ ],
+ },
+ ]
+
+ const result = convertToAiSdkMessages(messages)
+
+ expect(result).toHaveLength(1)
+ // Tool results go to role: "tool" messages
+ expect(result[0]).toEqual({
+ role: "tool",
+ content: [
+ {
+ type: "tool-result",
+ toolCallId: "call_orphan",
+ toolName: "unknown_tool",
+ output: { type: "text", value: "Orphan result" },
+ },
+ ],
+ })
+ })
+
+ it("separates tool results and text content into different messages", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "tool_use",
+ id: "call_123",
+ name: "read_file",
+ input: { path: "test.ts" },
+ },
+ ],
+ },
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: "call_123",
+ content: "File contents here",
+ },
+ {
+ type: "text",
+ text: "Please analyze this file",
+ },
+ ],
+ },
+ ]
+
+ const result = convertToAiSdkMessages(messages)
+
+ expect(result).toHaveLength(3)
+ expect(result[0]).toEqual({
+ role: "assistant",
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "call_123",
+ toolName: "read_file",
+ input: { path: "test.ts" },
+ },
+ ],
+ })
+ // Tool results go first in a "tool" message
+ expect(result[1]).toEqual({
+ role: "tool",
+ content: [
+ {
+ type: "tool-result",
+ toolCallId: "call_123",
+ toolName: "read_file",
+ output: { type: "text", value: "File contents here" },
+ },
+ ],
+ })
+ // Text content goes in a separate "user" message
+ expect(result[2]).toEqual({
+ role: "user",
+ content: [{ type: "text", text: "Please analyze this file" }],
+ })
+ })
+
+ it("converts assistant messages with tool use", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "Let me read that file" },
+ {
+ type: "tool_use",
+ id: "call_456",
+ name: "read_file",
+ input: { path: "test.ts" },
+ },
+ ],
+ },
+ ]
+
+ const result = convertToAiSdkMessages(messages)
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({
+ role: "assistant",
+ content: [
+ { type: "text", text: "Let me read that file" },
+ {
+ type: "tool-call",
+ toolCallId: "call_456",
+ toolName: "read_file",
+ input: { path: "test.ts" },
+ },
+ ],
+ })
+ })
+
+ it("handles empty assistant content", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "assistant",
+ content: [],
+ },
+ ]
+
+ const result = convertToAiSdkMessages(messages)
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({
+ role: "assistant",
+ content: [{ type: "text", text: "" }],
+ })
+ })
+ })
+
+ describe("convertToolsForAiSdk", () => {
+ it("returns undefined for empty tools", () => {
+ expect(convertToolsForAiSdk(undefined)).toBeUndefined()
+ expect(convertToolsForAiSdk([])).toBeUndefined()
+ })
+
+ it("converts function tools to AI SDK format", () => {
+ const tools: OpenAI.Chat.ChatCompletionTool[] = [
+ {
+ type: "function",
+ function: {
+ name: "read_file",
+ description: "Read a file from disk",
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "File path" },
+ },
+ required: ["path"],
+ },
+ },
+ },
+ ]
+
+ const result = convertToolsForAiSdk(tools)
+
+ expect(result).toBeDefined()
+ expect(result!.read_file).toBeDefined()
+ expect(result!.read_file.description).toBe("Read a file from disk")
+ })
+
+ it("converts multiple tools", () => {
+ const tools: OpenAI.Chat.ChatCompletionTool[] = [
+ {
+ type: "function",
+ function: {
+ name: "read_file",
+ description: "Read a file",
+ parameters: {},
+ },
+ },
+ {
+ type: "function",
+ function: {
+ name: "write_file",
+ description: "Write a file",
+ parameters: {},
+ },
+ },
+ ]
+
+ const result = convertToolsForAiSdk(tools)
+
+ expect(result).toBeDefined()
+ expect(Object.keys(result!)).toHaveLength(2)
+ expect(result!.read_file).toBeDefined()
+ expect(result!.write_file).toBeDefined()
+ })
+ })
+
+ describe("processAiSdkStreamPart", () => {
+ it("processes text-delta chunks", () => {
+ const part = { type: "text-delta" as const, id: "1", text: "Hello" }
+ const chunks = [...processAiSdkStreamPart(part)]
+
+ expect(chunks).toHaveLength(1)
+ expect(chunks[0]).toEqual({ type: "text", text: "Hello" })
+ })
+
+ it("processes text chunks (fullStream format)", () => {
+ const part = { type: "text" as const, text: "Hello from fullStream" }
+ const chunks = [...processAiSdkStreamPart(part as any)]
+
+ expect(chunks).toHaveLength(1)
+ expect(chunks[0]).toEqual({ type: "text", text: "Hello from fullStream" })
+ })
+
+ it("processes reasoning-delta chunks", () => {
+ const part = { type: "reasoning-delta" as const, id: "1", text: "thinking..." }
+ const chunks = [...processAiSdkStreamPart(part)]
+
+ expect(chunks).toHaveLength(1)
+ expect(chunks[0]).toEqual({ type: "reasoning", text: "thinking..." })
+ })
+
+ it("processes reasoning chunks (fullStream format)", () => {
+ const part = { type: "reasoning" as const, text: "reasoning from fullStream" }
+ const chunks = [...processAiSdkStreamPart(part as any)]
+
+ expect(chunks).toHaveLength(1)
+ expect(chunks[0]).toEqual({ type: "reasoning", text: "reasoning from fullStream" })
+ })
+
+ it("processes tool-input-start chunks", () => {
+ const part = { type: "tool-input-start" as const, id: "call_1", toolName: "read_file" }
+ const chunks = [...processAiSdkStreamPart(part)]
+
+ expect(chunks).toHaveLength(1)
+ expect(chunks[0]).toEqual({ type: "tool_call_start", id: "call_1", name: "read_file" })
+ })
+
+ it("processes tool-input-delta chunks", () => {
+ const part = { type: "tool-input-delta" as const, id: "call_1", delta: '{"path":' }
+ const chunks = [...processAiSdkStreamPart(part)]
+
+ expect(chunks).toHaveLength(1)
+ expect(chunks[0]).toEqual({ type: "tool_call_delta", id: "call_1", delta: '{"path":' })
+ })
+
+ it("processes tool-input-end chunks", () => {
+ const part = { type: "tool-input-end" as const, id: "call_1" }
+ const chunks = [...processAiSdkStreamPart(part)]
+
+ expect(chunks).toHaveLength(1)
+ expect(chunks[0]).toEqual({ type: "tool_call_end", id: "call_1" })
+ })
+
+ 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",
+ toolName: "read_file",
+ input: { path: "test.ts" },
+ }
+ const chunks = [...processAiSdkStreamPart(part)]
+
+ expect(chunks).toHaveLength(0)
+ })
+
+ it("processes source chunks with URL", () => {
+ const part = {
+ type: "source" as const,
+ url: "https://example.com",
+ title: "Example Source",
+ }
+ const chunks = [...processAiSdkStreamPart(part as any)]
+
+ expect(chunks).toHaveLength(1)
+ expect(chunks[0]).toEqual({
+ type: "grounding",
+ sources: [
+ {
+ title: "Example Source",
+ url: "https://example.com",
+ snippet: undefined,
+ },
+ ],
+ })
+ })
+
+ it("processes error chunks", () => {
+ const part = { type: "error" as const, error: new Error("Test error") }
+ const chunks = [...processAiSdkStreamPart(part)]
+
+ expect(chunks).toHaveLength(1)
+ expect(chunks[0]).toEqual({
+ type: "error",
+ error: "StreamError",
+ message: "Test error",
+ })
+ })
+
+ it("ignores lifecycle events", () => {
+ const lifecycleEvents = [
+ { type: "text-start" as const },
+ { type: "text-end" as const },
+ { type: "reasoning-start" as const },
+ { type: "reasoning-end" as const },
+ { type: "start-step" as const },
+ { type: "finish-step" as const },
+ { type: "start" as const },
+ { type: "finish" as const },
+ { type: "abort" as const },
+ ]
+
+ for (const event of lifecycleEvents) {
+ const chunks = [...processAiSdkStreamPart(event as any)]
+ expect(chunks).toHaveLength(0)
+ }
+ })
+ })
+
+ 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/__tests__/bedrock-converse-format.spec.ts b/src/api/transform/__tests__/bedrock-converse-format.spec.ts
index 7daf186f47..27319c6562 100644
--- a/src/api/transform/__tests__/bedrock-converse-format.spec.ts
+++ b/src/api/transform/__tests__/bedrock-converse-format.spec.ts
@@ -3,6 +3,7 @@
import { convertToBedrockConverseMessages } from "../bedrock-converse-format"
import { Anthropic } from "@anthropic-ai/sdk"
import { ContentBlock, ToolResultContentBlock } from "@aws-sdk/client-bedrock-runtime"
+import { OPENAI_CALL_ID_MAX_LENGTH } from "../../../utils/tool-id"
describe("convertToBedrockConverseMessages", () => {
it("converts simple text messages correctly", () => {
@@ -67,7 +68,7 @@ describe("convertToBedrockConverseMessages", () => {
}
})
- it("converts tool use messages correctly (default XML format)", () => {
+ it("converts tool use messages correctly (native tools format; default)", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
@@ -84,7 +85,6 @@ describe("convertToBedrockConverseMessages", () => {
},
]
- // Default behavior (useNativeTools: false) converts tool_use to XML text format
const result = convertToBedrockConverseMessages(messages)
if (!result[0] || !result[0].content) {
@@ -93,13 +93,15 @@ describe("convertToBedrockConverseMessages", () => {
}
expect(result[0].role).toBe("assistant")
- const textBlock = result[0].content[0] as ContentBlock
- if ("text" in textBlock) {
- expect(textBlock.text).toContain("")
- expect(textBlock.text).toContain("read_file ")
- expect(textBlock.text).toContain("test.txt")
+ const toolBlock = result[0].content[0] as ContentBlock
+ if ("toolUse" in toolBlock && toolBlock.toolUse) {
+ expect(toolBlock.toolUse).toEqual({
+ toolUseId: "test-id",
+ name: "read_file",
+ input: { path: "test.txt" },
+ })
} else {
- expect.fail("Expected text block with XML content not found")
+ expect.fail("Expected tool use block not found")
}
})
@@ -120,8 +122,7 @@ describe("convertToBedrockConverseMessages", () => {
},
]
- // With useNativeTools: true, keeps tool_use as native format
- const result = convertToBedrockConverseMessages(messages, { useNativeTools: true })
+ const result = convertToBedrockConverseMessages(messages)
if (!result[0] || !result[0].content) {
expect.fail("Expected result to have content")
@@ -141,7 +142,7 @@ describe("convertToBedrockConverseMessages", () => {
}
})
- it("converts tool result messages to XML text format (default, useNativeTools: false)", () => {
+ it("converts tool result messages to native format (default)", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
@@ -155,8 +156,6 @@ describe("convertToBedrockConverseMessages", () => {
},
]
- // Default behavior (useNativeTools: false) converts tool_result to XML text format
- // This fixes the Bedrock error "toolConfig field must be defined when using toolUse and toolResult content blocks"
const result = convertToBedrockConverseMessages(messages)
if (!result[0] || !result[0].content) {
@@ -164,40 +163,6 @@ describe("convertToBedrockConverseMessages", () => {
return
}
- expect(result[0].role).toBe("user")
- const textBlock = result[0].content[0] as ContentBlock
- if ("text" in textBlock) {
- expect(textBlock.text).toContain("")
- expect(textBlock.text).toContain("test-id ")
- expect(textBlock.text).toContain("File contents here")
- expect(textBlock.text).toContain(" ")
- } else {
- expect.fail("Expected text block with XML content not found")
- }
- })
-
- it("converts tool result messages to native format (useNativeTools: true)", () => {
- const messages: Anthropic.Messages.MessageParam[] = [
- {
- role: "user",
- content: [
- {
- type: "tool_result",
- tool_use_id: "test-id",
- content: [{ type: "text", text: "File contents here" }],
- },
- ],
- },
- ]
-
- // With useNativeTools: true, keeps tool_result as native format
- const result = convertToBedrockConverseMessages(messages, { useNativeTools: true })
-
- if (!result[0] || !result[0].content) {
- expect.fail("Expected result to have content")
- return
- }
-
expect(result[0].role).toBe("user")
const resultBlock = result[0].content[0] as ContentBlock
if ("toolResult" in resultBlock && resultBlock.toolResult) {
@@ -212,7 +177,42 @@ describe("convertToBedrockConverseMessages", () => {
}
})
- it("converts tool result messages with string content to XML text format (default)", () => {
+ it("converts tool result messages to native format", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: "test-id",
+ content: [{ type: "text", text: "File contents here" }],
+ },
+ ],
+ },
+ ]
+
+ const result = convertToBedrockConverseMessages(messages)
+
+ if (!result[0] || !result[0].content) {
+ expect.fail("Expected result to have content")
+ return
+ }
+
+ expect(result[0].role).toBe("user")
+ const resultBlock = result[0].content[0] as ContentBlock
+ if ("toolResult" in resultBlock && resultBlock.toolResult) {
+ const expectedContent: ToolResultContentBlock[] = [{ text: "File contents here" }]
+ expect(resultBlock.toolResult).toEqual({
+ toolUseId: "test-id",
+ content: expectedContent,
+ status: "success",
+ })
+ } else {
+ expect.fail("Expected tool result block not found")
+ }
+ })
+
+ it("converts tool result messages with string content to native format (default)", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
@@ -234,18 +234,19 @@ describe("convertToBedrockConverseMessages", () => {
}
expect(result[0].role).toBe("user")
- const textBlock = result[0].content[0] as ContentBlock
- if ("text" in textBlock) {
- expect(textBlock.text).toContain("")
- expect(textBlock.text).toContain("test-id ")
- expect(textBlock.text).toContain("File: test.txt")
- expect(textBlock.text).toContain("Hello World")
+ const resultBlock = result[0].content[0] as ContentBlock
+ if ("toolResult" in resultBlock && resultBlock.toolResult) {
+ expect(resultBlock.toolResult).toEqual({
+ toolUseId: "test-id",
+ content: [{ text: "File: test.txt\nLines 1-5:\nHello World" }],
+ status: "success",
+ })
} else {
- expect.fail("Expected text block with XML content not found")
+ expect.fail("Expected tool result block not found")
}
})
- it("converts tool result messages with string content to native format (useNativeTools: true)", () => {
+ it("converts tool result messages with string content to native format", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
@@ -259,7 +260,7 @@ describe("convertToBedrockConverseMessages", () => {
},
]
- const result = convertToBedrockConverseMessages(messages, { useNativeTools: true })
+ const result = convertToBedrockConverseMessages(messages)
if (!result[0] || !result[0].content) {
expect.fail("Expected result to have content")
@@ -279,9 +280,7 @@ describe("convertToBedrockConverseMessages", () => {
}
})
- it("converts both tool_use and tool_result consistently when native tools disabled", () => {
- // This test ensures tool_use AND tool_result are both converted to XML text
- // when useNativeTools is false, preventing Bedrock toolConfig errors
+ it("keeps both tool_use and tool_result in native format by default", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
@@ -306,27 +305,16 @@ describe("convertToBedrockConverseMessages", () => {
},
]
- const result = convertToBedrockConverseMessages(messages) // default useNativeTools: false
+ const result = convertToBedrockConverseMessages(messages)
- // Both should be text blocks, not native toolUse/toolResult
+ // Both should be native toolUse/toolResult blocks
const assistantContent = result[0]?.content?.[0] as ContentBlock
const userContent = result[1]?.content?.[0] as ContentBlock
- // tool_use should be XML text
- expect("text" in assistantContent).toBe(true)
- if ("text" in assistantContent) {
- expect(assistantContent.text).toContain("")
- }
-
- // tool_result should also be XML text (this is what the fix addresses)
- expect("text" in userContent).toBe(true)
- if ("text" in userContent) {
- expect(userContent.text).toContain("")
- }
-
- // Neither should have native format
- expect("toolUse" in assistantContent).toBe(false)
- expect("toolResult" in userContent).toBe(false)
+ expect("toolUse" in assistantContent).toBe(true)
+ expect("toolResult" in userContent).toBe(true)
+ expect("text" in assistantContent).toBe(false)
+ expect("text" in userContent).toBe(false)
})
it("handles text content correctly", () => {
@@ -354,4 +342,218 @@ describe("convertToBedrockConverseMessages", () => {
const textBlock = result[0].content[0] as ContentBlock
expect(textBlock).toEqual({ text: "Hello world" })
})
+
+ describe("toolUseId sanitization for Bedrock 64-char limit", () => {
+ it("truncates toolUseId longer than 64 characters in tool_use blocks", () => {
+ const longId = "a".repeat(100)
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "tool_use",
+ id: longId,
+ name: "read_file",
+ input: { path: "test.txt" },
+ },
+ ],
+ },
+ ]
+
+ const result = convertToBedrockConverseMessages(messages)
+ const toolBlock = result[0]?.content?.[0] as ContentBlock
+
+ if ("toolUse" in toolBlock && toolBlock.toolUse && toolBlock.toolUse.toolUseId) {
+ expect(toolBlock.toolUse.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
+ expect(toolBlock.toolUse.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH)
+ expect(toolBlock.toolUse.toolUseId).toContain("_")
+ } else {
+ expect.fail("Expected tool use block not found")
+ }
+ })
+
+ it("truncates toolUseId longer than 64 characters in tool_result blocks with string content", () => {
+ const longId = "b".repeat(100)
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: longId,
+ content: "Result content",
+ } as any,
+ ],
+ },
+ ]
+
+ const result = convertToBedrockConverseMessages(messages)
+ const resultBlock = result[0]?.content?.[0] as ContentBlock
+
+ if ("toolResult" in resultBlock && resultBlock.toolResult && resultBlock.toolResult.toolUseId) {
+ expect(resultBlock.toolResult.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
+ expect(resultBlock.toolResult.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH)
+ expect(resultBlock.toolResult.toolUseId).toContain("_")
+ } else {
+ expect.fail("Expected tool result block not found")
+ }
+ })
+
+ it("truncates toolUseId longer than 64 characters in tool_result blocks with array content", () => {
+ const longId = "c".repeat(100)
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: longId,
+ content: [{ type: "text", text: "Result content" }],
+ },
+ ],
+ },
+ ]
+
+ const result = convertToBedrockConverseMessages(messages)
+ const resultBlock = result[0]?.content?.[0] as ContentBlock
+
+ if ("toolResult" in resultBlock && resultBlock.toolResult && resultBlock.toolResult.toolUseId) {
+ expect(resultBlock.toolResult.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
+ expect(resultBlock.toolResult.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH)
+ } else {
+ expect.fail("Expected tool result block not found")
+ }
+ })
+
+ it("keeps toolUseId unchanged when under 64 characters", () => {
+ const shortId = "short-id-123"
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "tool_use",
+ id: shortId,
+ name: "read_file",
+ input: { path: "test.txt" },
+ },
+ ],
+ },
+ ]
+
+ const result = convertToBedrockConverseMessages(messages)
+ const toolBlock = result[0]?.content?.[0] as ContentBlock
+
+ if ("toolUse" in toolBlock && toolBlock.toolUse) {
+ expect(toolBlock.toolUse.toolUseId).toBe(shortId)
+ } else {
+ expect.fail("Expected tool use block not found")
+ }
+ })
+
+ it("produces consistent truncated IDs for the same input", () => {
+ const longId = "d".repeat(100)
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "tool_use",
+ id: longId,
+ name: "read_file",
+ input: { path: "test.txt" },
+ },
+ ],
+ },
+ ]
+
+ const result1 = convertToBedrockConverseMessages(messages)
+ const result2 = convertToBedrockConverseMessages(messages)
+
+ const toolBlock1 = result1[0]?.content?.[0] as ContentBlock
+ const toolBlock2 = result2[0]?.content?.[0] as ContentBlock
+
+ if ("toolUse" in toolBlock1 && toolBlock1.toolUse && "toolUse" in toolBlock2 && toolBlock2.toolUse) {
+ expect(toolBlock1.toolUse.toolUseId).toBe(toolBlock2.toolUse.toolUseId)
+ } else {
+ expect.fail("Expected tool use blocks not found")
+ }
+ })
+
+ it("produces different truncated IDs for different long inputs", () => {
+ const longId1 = "e".repeat(100)
+ const longId2 = "f".repeat(100)
+
+ const messages1: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "assistant",
+ content: [{ type: "tool_use", id: longId1, name: "read_file", input: {} }],
+ },
+ ]
+ const messages2: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "assistant",
+ content: [{ type: "tool_use", id: longId2, name: "read_file", input: {} }],
+ },
+ ]
+
+ const result1 = convertToBedrockConverseMessages(messages1)
+ const result2 = convertToBedrockConverseMessages(messages2)
+
+ const toolBlock1 = result1[0]?.content?.[0] as ContentBlock
+ const toolBlock2 = result2[0]?.content?.[0] as ContentBlock
+
+ if ("toolUse" in toolBlock1 && toolBlock1.toolUse && "toolUse" in toolBlock2 && toolBlock2.toolUse) {
+ expect(toolBlock1.toolUse.toolUseId).not.toBe(toolBlock2.toolUse.toolUseId)
+ } else {
+ expect.fail("Expected tool use blocks not found")
+ }
+ })
+
+ it("matching tool_use and tool_result IDs are both truncated consistently", () => {
+ const longId = "g".repeat(100)
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "tool_use",
+ id: longId,
+ name: "read_file",
+ input: { path: "test.txt" },
+ },
+ ],
+ },
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: longId,
+ content: "File contents",
+ } as any,
+ ],
+ },
+ ]
+
+ const result = convertToBedrockConverseMessages(messages)
+
+ const toolUseBlock = result[0]?.content?.[0] as ContentBlock
+ const toolResultBlock = result[1]?.content?.[0] as ContentBlock
+
+ if (
+ "toolUse" in toolUseBlock &&
+ toolUseBlock.toolUse &&
+ toolUseBlock.toolUse.toolUseId &&
+ "toolResult" in toolResultBlock &&
+ toolResultBlock.toolResult &&
+ toolResultBlock.toolResult.toolUseId
+ ) {
+ expect(toolUseBlock.toolUse.toolUseId).toBe(toolResultBlock.toolResult.toolUseId)
+ expect(toolUseBlock.toolUse.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
+ } else {
+ expect.fail("Expected tool use and result blocks not found")
+ }
+ })
+ })
})
diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts
new file mode 100644
index 0000000000..ebbf1a8661
--- /dev/null
+++ b/src/api/transform/ai-sdk.ts
@@ -0,0 +1,397 @@
+/**
+ * AI SDK conversion utilities for transforming between Anthropic/OpenAI formats and Vercel AI SDK formats.
+ * These utilities are designed to be reused across different AI SDK providers.
+ */
+
+import { Anthropic } from "@anthropic-ai/sdk"
+import OpenAI from "openai"
+import { tool as createTool, jsonSchema, type ModelMessage, type TextStreamPart } from "ai"
+import type { ApiStreamChunk } from "./stream"
+
+/**
+ * Convert Anthropic messages to AI SDK ModelMessage format.
+ * Handles text, images, tool uses, and tool results.
+ *
+ * @param messages - Array of Anthropic message parameters
+ * @returns Array of AI SDK ModelMessage objects
+ */
+export function convertToAiSdkMessages(messages: Anthropic.Messages.MessageParam[]): ModelMessage[] {
+ const modelMessages: ModelMessage[] = []
+
+ // First pass: build a map of tool call IDs to tool names from assistant messages
+ const toolCallIdToName = new Map()
+ for (const message of messages) {
+ if (message.role === "assistant" && typeof message.content !== "string") {
+ for (const part of message.content) {
+ if (part.type === "tool_use") {
+ toolCallIdToName.set(part.id, part.name)
+ }
+ }
+ }
+ }
+
+ for (const message of messages) {
+ if (typeof message.content === "string") {
+ modelMessages.push({
+ role: message.role,
+ content: message.content,
+ })
+ } else {
+ if (message.role === "user") {
+ const parts: Array<
+ { type: "text"; text: string } | { type: "image"; image: string; mimeType?: string }
+ > = []
+ const toolResults: Array<{
+ type: "tool-result"
+ toolCallId: string
+ toolName: string
+ output: { type: "text"; value: string }
+ }> = []
+
+ for (const part of message.content) {
+ if (part.type === "text") {
+ parts.push({ type: "text", text: part.text })
+ } else if (part.type === "image") {
+ // Handle both base64 and URL source types
+ const source = part.source as { type: string; media_type?: string; data?: string; url?: string }
+ if (source.type === "base64" && source.media_type && source.data) {
+ parts.push({
+ type: "image",
+ image: `data:${source.media_type};base64,${source.data}`,
+ mimeType: source.media_type,
+ })
+ } else if (source.type === "url" && source.url) {
+ parts.push({
+ type: "image",
+ image: source.url,
+ })
+ }
+ } else if (part.type === "tool_result") {
+ // Convert tool results to string content
+ let content: string
+ if (typeof part.content === "string") {
+ content = part.content
+ } else {
+ content =
+ part.content
+ ?.map((c) => {
+ if (c.type === "text") return c.text
+ if (c.type === "image") return "(image)"
+ return ""
+ })
+ .join("\n") ?? ""
+ }
+ // Look up the tool name from the tool call ID
+ const toolName = toolCallIdToName.get(part.tool_use_id) ?? "unknown_tool"
+ toolResults.push({
+ type: "tool-result",
+ toolCallId: part.tool_use_id,
+ toolName,
+ output: { type: "text", value: content || "(empty)" },
+ })
+ }
+ }
+
+ // AI SDK requires tool results in separate "tool" role messages
+ // UserContent only supports: string | Array
+ // ToolContent (for role: "tool") supports: Array
+ if (toolResults.length > 0) {
+ modelMessages.push({
+ role: "tool",
+ content: toolResults,
+ } as ModelMessage)
+ }
+
+ // Add user message with only text/image content (no tool results)
+ if (parts.length > 0) {
+ modelMessages.push({
+ role: "user",
+ content: parts,
+ } as ModelMessage)
+ }
+ } else if (message.role === "assistant") {
+ const textParts: string[] = []
+ const toolCalls: Array<{
+ type: "tool-call"
+ toolCallId: string
+ toolName: string
+ input: unknown
+ }> = []
+
+ for (const part of message.content) {
+ if (part.type === "text") {
+ textParts.push(part.text)
+ } else if (part.type === "tool_use") {
+ toolCalls.push({
+ type: "tool-call",
+ toolCallId: part.id,
+ toolName: part.name,
+ input: part.input,
+ })
+ }
+ }
+
+ const content: Array<
+ | { type: "text"; text: string }
+ | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }
+ > = []
+
+ if (textParts.length > 0) {
+ content.push({ type: "text", text: textParts.join("\n") })
+ }
+ content.push(...toolCalls)
+
+ modelMessages.push({
+ role: "assistant",
+ content: content.length > 0 ? content : [{ type: "text", text: "" }],
+ } as ModelMessage)
+ }
+ }
+ }
+
+ return modelMessages
+}
+
+/**
+ * Convert OpenAI-style function tool definitions to AI SDK tool format.
+ *
+ * @param tools - Array of OpenAI tool definitions
+ * @returns Record of AI SDK tools keyed by tool name, or undefined if no tools
+ */
+export function convertToolsForAiSdk(
+ tools: OpenAI.Chat.ChatCompletionTool[] | undefined,
+): Record> | undefined {
+ if (!tools || tools.length === 0) {
+ return undefined
+ }
+
+ const toolSet: Record> = {}
+
+ for (const t of tools) {
+ if (t.type === "function") {
+ toolSet[t.function.name] = createTool({
+ description: t.function.description,
+ inputSchema: jsonSchema(t.function.parameters as any),
+ })
+ }
+ }
+
+ return toolSet
+}
+
+/**
+ * Extended stream part type that includes additional fullStream event types
+ * that are emitted at runtime but not included in the AI SDK TextStreamPart type definitions.
+ */
+type ExtendedStreamPart = TextStreamPart | { type: "text"; text: string } | { type: "reasoning"; text: string }
+
+/**
+ * Process a single AI SDK stream part and yield the appropriate ApiStreamChunk(s).
+ * This generator handles all TextStreamPart types and converts them to the
+ * ApiStreamChunk format used by the application.
+ *
+ * @param part - The AI SDK TextStreamPart to process (including fullStream event types)
+ * @yields ApiStreamChunk objects corresponding to the stream part
+ */
+export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator {
+ switch (part.type) {
+ case "text":
+ case "text-delta":
+ yield { type: "text", text: (part as { text: string }).text }
+ break
+
+ case "reasoning":
+ case "reasoning-delta":
+ yield { type: "reasoning", text: (part as { text: string }).text }
+ break
+
+ case "tool-input-start":
+ yield {
+ type: "tool_call_start",
+ id: part.id,
+ name: part.toolName,
+ }
+ break
+
+ case "tool-input-delta":
+ yield {
+ type: "tool_call_delta",
+ id: part.id,
+ delta: part.delta,
+ }
+ break
+
+ case "tool-input-end":
+ yield {
+ type: "tool_call_end",
+ id: part.id,
+ }
+ break
+
+ case "source":
+ // Handle both URL and document source types
+ if ("url" in part) {
+ yield {
+ type: "grounding",
+ sources: [
+ {
+ title: part.title || "Source",
+ url: part.url,
+ snippet: undefined,
+ },
+ ],
+ }
+ }
+ break
+
+ case "error":
+ yield {
+ type: "error",
+ error: "StreamError",
+ message: part.error instanceof Error ? part.error.message : String(part.error),
+ }
+ break
+
+ // Ignore lifecycle events that don't need to yield chunks.
+ // Note: 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).
+ case "text-start":
+ case "text-end":
+ case "reasoning-start":
+ case "reasoning-end":
+ case "start-step":
+ case "finish-step":
+ case "start":
+ case "finish":
+ case "abort":
+ case "file":
+ case "tool-result":
+ case "tool-error":
+ case "tool-call":
+ case "raw":
+ break
+ }
+}
+
+/**
+ * Type for AI SDK tool choice format.
+ */
+export type AiSdkToolChoice = "auto" | "none" | "required" | { type: "tool"; toolName: string } | undefined
+
+/**
+ * Map OpenAI-style tool_choice to AI SDK toolChoice format.
+ * This is a shared utility to avoid duplication across providers.
+ *
+ * @param toolChoice - OpenAI-style tool choice (string or object)
+ * @returns AI SDK toolChoice format
+ */
+export function mapToolChoice(toolChoice: any): AiSdkToolChoice {
+ 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
+}
+
+/**
+ * Extract a user-friendly error message from AI SDK errors.
+ * The AI SDK wraps errors in types like AI_RetryError and AI_APICallError
+ * which need to be unwrapped to get the actual error message.
+ *
+ * @param error - The error to extract the message from
+ * @returns A user-friendly error message
+ */
+export function extractAiSdkErrorMessage(error: unknown): string {
+ if (!error) {
+ return "Unknown error"
+ }
+
+ // Cast to access AI SDK error properties
+ const anyError = error as any
+
+ // AI_RetryError has a lastError property with the actual error
+ if (anyError.name === "AI_RetryError") {
+ const retryCount = anyError.errors?.length || 0
+ const lastError = anyError.lastError
+ const lastErrorMessage = lastError?.message || lastError?.toString() || "Unknown error"
+
+ // Extract status code if available
+ const statusCode =
+ lastError?.status || lastError?.statusCode || anyError.status || anyError.statusCode || undefined
+
+ if (statusCode) {
+ return `Failed after ${retryCount} attempts (${statusCode}): ${lastErrorMessage}`
+ }
+ return `Failed after ${retryCount} attempts: ${lastErrorMessage}`
+ }
+
+ // AI_APICallError has message and optional status
+ if (anyError.name === "AI_APICallError") {
+ const statusCode = anyError.status || anyError.statusCode
+ if (statusCode) {
+ return `API Error (${statusCode}): ${anyError.message}`
+ }
+ return anyError.message || "API call failed"
+ }
+
+ // Standard Error
+ if (error instanceof Error) {
+ return error.message
+ }
+
+ // Fallback for non-Error objects
+ return String(error)
+}
+
+/**
+ * Handle AI SDK errors by extracting the message and preserving status codes.
+ * Returns an Error object with proper status preserved for retry logic.
+ *
+ * @param error - The AI SDK error to handle
+ * @param providerName - The name of the provider for context
+ * @returns An Error with preserved status code
+ */
+export function handleAiSdkError(error: unknown, providerName: string): Error {
+ const message = extractAiSdkErrorMessage(error)
+ const wrappedError = new Error(`${providerName}: ${message}`)
+
+ // Preserve status code for retry logic
+ const anyError = error as any
+ const statusCode =
+ anyError?.lastError?.status ||
+ anyError?.lastError?.statusCode ||
+ anyError?.status ||
+ anyError?.statusCode ||
+ undefined
+
+ if (statusCode) {
+ ;(wrappedError as any).status = statusCode
+ }
+
+ // Preserve the original error for debugging
+ ;(wrappedError as any).cause = error
+
+ return wrappedError
+}
diff --git a/src/api/transform/bedrock-converse-format.ts b/src/api/transform/bedrock-converse-format.ts
index 1a8e49a20b..2a49d72bce 100644
--- a/src/api/transform/bedrock-converse-format.ts
+++ b/src/api/transform/bedrock-converse-format.ts
@@ -1,5 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ConversationRole, Message, ContentBlock } from "@aws-sdk/client-bedrock-runtime"
+import { sanitizeOpenAiCallId } from "../../utils/tool-id"
interface BedrockMessageContent {
type: "text" | "image" | "video" | "tool_use" | "tool_result"
@@ -25,14 +26,8 @@ interface BedrockMessageContent {
/**
* Convert Anthropic messages to Bedrock Converse format
* @param anthropicMessages Messages in Anthropic format
- * @param options Optional configuration for conversion
- * @param options.useNativeTools When true, keeps tool_use input as JSON object instead of XML string
*/
-export function convertToBedrockConverseMessages(
- anthropicMessages: Anthropic.Messages.MessageParam[],
- options?: { useNativeTools?: boolean },
-): Message[] {
- const useNativeTools = options?.useNativeTools ?? false
+export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] {
return anthropicMessages.map((anthropicMessage) => {
// Map Anthropic roles to Bedrock roles
const role: ConversationRole = anthropicMessage.role === "assistant" ? "assistant" : "user"
@@ -93,74 +88,24 @@ export function convertToBedrockConverseMessages(
}
if (messageBlock.type === "tool_use") {
- if (useNativeTools) {
- // For native tool calling, keep input as JSON object for Bedrock's toolUse format
- return {
- toolUse: {
- toolUseId: messageBlock.id || "",
- name: messageBlock.name || "",
- input: messageBlock.input || {},
- },
- } as ContentBlock
- } else {
- // Convert tool use to XML text format for XML-based tool calling
- return {
- text: `\n${messageBlock.name} \n${JSON.stringify(messageBlock.input)} \n `,
- } as ContentBlock
- }
+ // Native-only: keep input as JSON object for Bedrock's toolUse format
+ return {
+ toolUse: {
+ toolUseId: sanitizeOpenAiCallId(messageBlock.id || ""),
+ name: messageBlock.name || "",
+ input: messageBlock.input || {},
+ },
+ } as ContentBlock
}
if (messageBlock.type === "tool_result") {
- // When NOT using native tools, convert tool_result to text format
- // This matches how tool_use is converted to XML text when native tools are disabled.
- // Without this, Bedrock will error with "toolConfig field must be defined when using
- // toolUse and toolResult content blocks" because toolResult blocks require toolConfig.
- if (!useNativeTools) {
- let toolResultContent: string
- if (messageBlock.content) {
- if (typeof messageBlock.content === "string") {
- toolResultContent = messageBlock.content
- } else if (Array.isArray(messageBlock.content)) {
- toolResultContent = messageBlock.content
- .map((item) => (typeof item === "string" ? item : item.text || String(item)))
- .join("\n")
- } else {
- toolResultContent = String(messageBlock.output || "")
- }
- } else if (messageBlock.output) {
- if (typeof messageBlock.output === "string") {
- toolResultContent = messageBlock.output
- } else if (Array.isArray(messageBlock.output)) {
- toolResultContent = messageBlock.output
- .map((part) => {
- if (typeof part === "object" && "text" in part) {
- return part.text
- }
- if (typeof part === "object" && "type" in part && part.type === "image") {
- return "(see following message for image)"
- }
- return String(part)
- })
- .join("\n")
- } else {
- toolResultContent = String(messageBlock.output)
- }
- } else {
- toolResultContent = ""
- }
-
- return {
- text: `\n${messageBlock.tool_use_id || ""} \n${toolResultContent} \n `,
- } as ContentBlock
- }
-
// Handle content field - can be string or array (native tool format)
if (messageBlock.content) {
// Content is a string
if (typeof messageBlock.content === "string") {
return {
toolResult: {
- toolUseId: messageBlock.tool_use_id || "",
+ toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
content: [
{
text: messageBlock.content,
@@ -174,7 +119,7 @@ export function convertToBedrockConverseMessages(
if (Array.isArray(messageBlock.content)) {
return {
toolResult: {
- toolUseId: messageBlock.tool_use_id || "",
+ toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
content: messageBlock.content.map((item) => ({
text: typeof item === "string" ? item : item.text || String(item),
})),
@@ -188,7 +133,7 @@ export function convertToBedrockConverseMessages(
if (messageBlock.output && typeof messageBlock.output === "string") {
return {
toolResult: {
- toolUseId: messageBlock.tool_use_id || "",
+ toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
content: [
{
text: messageBlock.output,
@@ -202,7 +147,7 @@ export function convertToBedrockConverseMessages(
if (Array.isArray(messageBlock.output)) {
return {
toolResult: {
- toolUseId: messageBlock.tool_use_id || "",
+ toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
content: messageBlock.output.map((part) => {
if (typeof part === "object" && "text" in part) {
return { text: part.text }
@@ -221,7 +166,7 @@ export function convertToBedrockConverseMessages(
// Default case
return {
toolResult: {
- toolUseId: messageBlock.tool_use_id || "",
+ toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
content: [
{
text: String(messageBlock.output || ""),
diff --git a/src/api/transform/model-params.ts b/src/api/transform/model-params.ts
index 9e1d421f6f..e862c5cf5e 100644
--- a/src/api/transform/model-params.ts
+++ b/src/api/transform/model-params.ts
@@ -163,7 +163,7 @@ export function getModelParams({
format,
...params,
reasoning: getOpenAiReasoning({ model, reasoningBudget, reasoningEffort, settings }),
- tools: model.supportsNativeTools,
+ // Whether tools are included is determined by whether the caller provided tool definitions.
}
} else if (format === "gemini") {
return {
diff --git a/src/core/assistant-message/AssistantMessageParser.ts b/src/core/assistant-message/AssistantMessageParser.ts
deleted file mode 100644
index 364ec603f2..0000000000
--- a/src/core/assistant-message/AssistantMessageParser.ts
+++ /dev/null
@@ -1,251 +0,0 @@
-import { type ToolName, toolNames } from "@roo-code/types"
-import { TextContent, ToolUse, ToolParamName, toolParamNames } from "../../shared/tools"
-import { AssistantMessageContent } from "./parseAssistantMessage"
-
-/**
- * Parser for assistant messages. Maintains state between chunks
- * to avoid reprocessing the entire message on each update.
- */
-export class AssistantMessageParser {
- private contentBlocks: AssistantMessageContent[] = []
- private currentTextContent: TextContent | undefined = undefined
- private currentTextContentStartIndex = 0
- private currentToolUse: ToolUse | undefined = undefined
- private currentToolUseStartIndex = 0
- private currentParamName: ToolParamName | undefined = undefined
- private currentParamValueStartIndex = 0
- private readonly MAX_ACCUMULATOR_SIZE = 1024 * 1024 // 1MB limit
- private readonly MAX_PARAM_LENGTH = 1024 * 100 // 100KB per parameter limit
- private accumulator = ""
-
- /**
- * Initialize a new AssistantMessageParser instance.
- */
- constructor() {
- this.reset()
- }
-
- /**
- * Reset the parser state.
- */
- public reset(): void {
- this.contentBlocks = []
- this.currentTextContent = undefined
- this.currentTextContentStartIndex = 0
- this.currentToolUse = undefined
- this.currentToolUseStartIndex = 0
- this.currentParamName = undefined
- this.currentParamValueStartIndex = 0
- this.accumulator = ""
- }
-
- /**
- * Returns the current parsed content blocks
- */
-
- public getContentBlocks(): AssistantMessageContent[] {
- // Return a shallow copy to prevent external mutation
- return this.contentBlocks.slice()
- }
- /**
- * Process a new chunk of text and update the parser state.
- * @param chunk The new chunk of text to process.
- */
- public processChunk(chunk: string): AssistantMessageContent[] {
- if (this.accumulator.length + chunk.length > this.MAX_ACCUMULATOR_SIZE) {
- throw new Error("Assistant message exceeds maximum allowed size")
- }
- // Store the current length of the accumulator before adding the new chunk
- const accumulatorStartLength = this.accumulator.length
-
- for (let i = 0; i < chunk.length; i++) {
- const char = chunk[i]
- this.accumulator += char
- const currentPosition = accumulatorStartLength + i
-
- // There should not be a param without a tool use.
- if (this.currentToolUse && this.currentParamName) {
- const currentParamValue = this.accumulator.slice(this.currentParamValueStartIndex)
- if (currentParamValue.length > this.MAX_PARAM_LENGTH) {
- // Reset to a safe state
- this.currentParamName = undefined
- this.currentParamValueStartIndex = 0
- continue
- }
- const paramClosingTag = `${this.currentParamName}>`
- // Streamed param content: always write the currently accumulated value
- if (currentParamValue.endsWith(paramClosingTag)) {
- // End of param value.
- // Do not trim content parameters to preserve newlines, but strip first and last newline only
- const paramValue = currentParamValue.slice(0, -paramClosingTag.length)
- this.currentToolUse.params[this.currentParamName] =
- this.currentParamName === "content"
- ? paramValue.replace(/^\n/, "").replace(/\n$/, "")
- : paramValue.trim()
- this.currentParamName = undefined
- continue
- } else {
- // Partial param value is accumulating.
- // Write the currently accumulated param content in real time
- this.currentToolUse.params[this.currentParamName] = currentParamValue
- continue
- }
- }
-
- // No currentParamName.
-
- if (this.currentToolUse) {
- const currentToolValue = this.accumulator.slice(this.currentToolUseStartIndex)
- const toolUseClosingTag = `${this.currentToolUse.name}>`
- if (currentToolValue.endsWith(toolUseClosingTag)) {
- // End of a tool use.
- this.currentToolUse.partial = false
-
- this.currentToolUse = undefined
- continue
- } else {
- const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`)
- for (const paramOpeningTag of possibleParamOpeningTags) {
- if (this.accumulator.endsWith(paramOpeningTag)) {
- // Start of a new parameter.
- const paramName = paramOpeningTag.slice(1, -1)
- if (!toolParamNames.includes(paramName as ToolParamName)) {
- // Handle invalid parameter name gracefully
- continue
- }
- this.currentParamName = paramName as ToolParamName
- this.currentParamValueStartIndex = this.accumulator.length
- break
- }
- }
-
- // There's no current param, and not starting a new param.
-
- // Special case for write_to_file where file contents could
- // contain the closing tag, in which case the param would have
- // closed and we end up with the rest of the file contents here.
- // To work around this, get the string between the starting
- // content tag and the LAST content tag.
- const contentParamName: ToolParamName = "content"
-
- if (
- this.currentToolUse.name === "write_to_file" &&
- this.accumulator.endsWith(`${contentParamName}>`)
- ) {
- const toolContent = this.accumulator.slice(this.currentToolUseStartIndex)
- const contentStartTag = `<${contentParamName}>`
- const contentEndTag = `${contentParamName}>`
- const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length
- const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
-
- if (contentStartIndex !== -1 && contentEndIndex !== -1 && contentEndIndex > contentStartIndex) {
- // Don't trim content to preserve newlines, but strip first and last newline only
- this.currentToolUse.params[contentParamName] = toolContent
- .slice(contentStartIndex, contentEndIndex)
- .replace(/^\n/, "")
- .replace(/\n$/, "")
- }
- }
-
- // Partial tool value is accumulating.
- continue
- }
- }
-
- // No currentToolUse.
-
- let didStartToolUse = false
- const possibleToolUseOpeningTags = toolNames.map((name) => `<${name}>`)
-
- for (const toolUseOpeningTag of possibleToolUseOpeningTags) {
- if (this.accumulator.endsWith(toolUseOpeningTag)) {
- // Extract and validate the tool name
- const extractedToolName = toolUseOpeningTag.slice(1, -1)
-
- // Check if the extracted tool name is valid
- if (!toolNames.includes(extractedToolName as ToolName)) {
- // Invalid tool name, treat as plain text and continue
- continue
- }
-
- // Start of a new tool use.
- this.currentToolUse = {
- type: "tool_use",
- name: extractedToolName as ToolName,
- params: {},
- partial: true,
- }
-
- this.currentToolUseStartIndex = this.accumulator.length
-
- // This also indicates the end of the current text content.
- if (this.currentTextContent) {
- this.currentTextContent.partial = false
-
- // Remove the partially accumulated tool use tag from the
- // end of text ( block === this.currentToolUse)
- if (idx === -1) {
- this.contentBlocks.push(this.currentToolUse)
- }
-
- didStartToolUse = true
- break
- }
- }
-
- if (!didStartToolUse) {
- // No tool use, so it must be text either at the beginning or
- // between tools.
- if (this.currentTextContent === undefined) {
- // If this is the first chunk and we're at the beginning of processing,
- // set the start index to the current position in the accumulator
- this.currentTextContentStartIndex = currentPosition
-
- // Create a new text content block and add it to contentBlocks
- this.currentTextContent = {
- type: "text",
- content: this.accumulator.slice(this.currentTextContentStartIndex).trim(),
- partial: true,
- }
-
- // Add the new text content to contentBlocks immediately
- // Ensures it appears in the UI right away
- this.contentBlocks.push(this.currentTextContent)
- } else {
- // Update the existing text content
- this.currentTextContent.content = this.accumulator.slice(this.currentTextContentStartIndex).trim()
- }
- }
- }
- // Do not call finalizeContentBlocks() here.
- // Instead, update any partial blocks in the array and add new ones as they're completed.
- // This matches the behavior of the original parseAssistantMessage function.
- return this.getContentBlocks()
- }
-
- /**
- * Finalize any partial content blocks.
- * Should be called after processing the last chunk.
- */
- public finalizeContentBlocks(): void {
- // Mark all partial blocks as complete
- for (const block of this.contentBlocks) {
- if (block.partial) {
- block.partial = false
- }
- if (block.type === "text" && typeof block.content === "string") {
- block.content = block.content.trim()
- }
- }
- }
-}
diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts
index 961b113d8d..b1322c65bf 100644
--- a/src/core/assistant-message/NativeToolCallParser.ts
+++ b/src/core/assistant-message/NativeToolCallParser.ts
@@ -73,6 +73,22 @@ export class NativeToolCallParser {
}
>()
+ private static coerceOptionalBoolean(value: unknown): boolean | undefined {
+ if (typeof value === "boolean") {
+ return value
+ }
+ if (typeof value === "string") {
+ const lower = value.trim().toLowerCase()
+ if (lower === "true") {
+ return true
+ }
+ if (lower === "false") {
+ return false
+ }
+ }
+ return undefined
+ }
+
/**
* Process a raw tool call chunk from the API stream.
* Handles tracking, buffering, and emits start/delta/end events.
@@ -297,9 +313,22 @@ export class NativeToolCallParser {
return finalToolUse
}
+ private static coerceOptionalNumber(value: unknown): number | undefined {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value
+ }
+ if (typeof value === "string") {
+ const n = Number(value)
+ if (Number.isFinite(n)) {
+ return n
+ }
+ }
+ return undefined
+ }
+
/**
* Convert raw file entries from API (with line_ranges) to FileEntry objects
- * (with lineRanges). Handles multiple formats for compatibility:
+ * (with lineRanges). Handles multiple formats for backward compatibility:
*
* New tuple format: { path: string, line_ranges: [[1, 50], [100, 150]] }
* Object format: { path: string, line_ranges: [{ start: 1, end: 50 }] }
@@ -307,19 +336,21 @@ export class NativeToolCallParser {
*
* Returns: { path: string, lineRanges: [{ start: 1, end: 50 }] }
*/
- private static convertFileEntries(files: any[]): FileEntry[] {
- return files.map((file: any) => {
- 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") {
@@ -330,7 +361,7 @@ export class NativeToolCallParser {
}
return null
})
- .filter(Boolean)
+ .filter((r): r is { start: number; end: number } => r !== null)
}
return entry
})
@@ -348,9 +379,9 @@ export class NativeToolCallParser {
partial: boolean,
originalName?: string,
): ToolUse | null {
- // Build legacy params for display
+ // Build stringified params for display/partial-progress UI.
// NOTE: For streaming partial updates, we MUST populate params even for complex types
- // because tool.handlePartial() methods rely on params to show UI updates
+ // because tool.handlePartial() methods rely on params to show UI updates.
const params: Partial> = {}
for (const [key, value] of Object.entries(partialArgs)) {
@@ -362,10 +393,60 @@ 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 "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
@@ -433,14 +514,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 = {
@@ -460,6 +533,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 = {
@@ -543,6 +625,25 @@ export class NativeToolCallParser {
}
break
+ case "list_files":
+ if (partialArgs.path !== undefined) {
+ nativeArgs = {
+ path: partialArgs.path,
+ recursive: this.coerceOptionalBoolean(partialArgs.recursive),
+ }
+ }
+ break
+
+ case "new_task":
+ if (partialArgs.mode !== undefined || partialArgs.message !== undefined) {
+ nativeArgs = {
+ mode: partialArgs.mode,
+ message: partialArgs.message,
+ todos: partialArgs.todos,
+ }
+ }
+ break
+
default:
break
}
@@ -560,6 +661,11 @@ export class NativeToolCallParser {
result.originalName = originalName
}
+ // Track legacy format usage for telemetry
+ if (usedLegacyFormat) {
+ result.usedLegacyFormat = true
+ }
+
return result
}
@@ -601,18 +707,11 @@ export class NativeToolCallParser {
// Parse the arguments JSON string
const args = toolCall.arguments === "" ? {} : JSON.parse(toolCall.arguments)
- // Build legacy params object for backward compatibility with XML protocol and UI.
- // Native execution path uses nativeArgs instead, which has proper typing.
+ // Build stringified params for display/logging.
+ // Tool execution MUST use nativeArgs (typed) and does not support legacy fallbacks.
const params: Partial> = {}
for (const [key, value] of Object.entries(args)) {
- // Skip complex parameters that have been migrated to nativeArgs.
- // For read_file, the 'files' parameter is a FileEntry[] array that can't be
- // meaningfully stringified. The properly typed data is in nativeArgs instead.
- if (resolvedName === "read_file" && key === "files") {
- continue
- }
-
// Validate parameter name
if (!toolParamNames.includes(key as ToolParamName) && !customToolRegistry.has(resolvedName)) {
console.warn(`Unknown parameter '${key}' for tool '${resolvedName}'`)
@@ -625,20 +724,63 @@ export class NativeToolCallParser {
params[key as ToolParamName] = stringValue
}
- // Build typed nativeArgs for tools that support it.
- // This switch statement serves two purposes:
- // 1. Validation: Ensures required parameters are present before constructing nativeArgs
- // 2. Transformation: Converts raw JSON to properly typed structures
- //
+ // Build typed nativeArgs for tool execution.
// Each case validates the minimum required parameters and constructs a properly typed
- // nativeArgs object. If validation fails, nativeArgs remains undefined and the tool
- // will fall back to legacy parameter parsing if supported.
+ // 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 (args.files && Array.isArray(args.files)) {
- nativeArgs = { files: this.convertFileEntries(args.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
@@ -706,14 +848,6 @@ export class NativeToolCallParser {
}
break
- case "fetch_instructions":
- if (args.task !== undefined) {
- nativeArgs = {
- task: args.task,
- } as NativeArgsFor
- }
- break
-
case "generate_image":
if (args.prompt !== undefined && args.path !== undefined) {
nativeArgs = {
@@ -733,6 +867,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 (args.path !== undefined && args.regex !== undefined) {
nativeArgs = {
@@ -760,6 +903,17 @@ export class NativeToolCallParser {
}
break
+ case "read_command_output":
+ if (args.artifact_id !== undefined) {
+ nativeArgs = {
+ artifact_id: args.artifact_id,
+ search: args.search,
+ offset: args.offset,
+ limit: args.limit,
+ } as NativeArgsFor
+ }
+ break
+
case "write_to_file":
if (args.path !== undefined && args.content !== undefined) {
nativeArgs = {
@@ -825,6 +979,25 @@ export class NativeToolCallParser {
}
break
+ case "list_files":
+ if (args.path !== undefined) {
+ nativeArgs = {
+ path: args.path,
+ recursive: this.coerceOptionalBoolean(args.recursive),
+ } as NativeArgsFor
+ }
+ break
+
+ case "new_task":
+ if (args.mode !== undefined && args.message !== undefined) {
+ nativeArgs = {
+ mode: args.mode,
+ message: args.message,
+ todos: args.todos,
+ } as NativeArgsFor
+ }
+ break
+
default:
if (customToolRegistry.has(resolvedName)) {
nativeArgs = args as NativeArgsFor
@@ -833,6 +1006,16 @@ export class NativeToolCallParser {
break
}
+ // Native-only: core tools must always have typed nativeArgs.
+ // If we couldn't construct it, the model produced an invalid tool call payload.
+ if (!nativeArgs && !customToolRegistry.has(resolvedName)) {
+ throw new Error(
+ `[NativeToolCallParser] Invalid arguments for tool '${resolvedName}'. ` +
+ `Native tool calls require a valid JSON payload matching the tool schema. ` +
+ `Received: ${JSON.stringify(args)}`,
+ )
+ }
+
const result: ToolUse = {
type: "tool_use" as const,
name: resolvedName,
@@ -846,6 +1029,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(
@@ -861,10 +1049,6 @@ export class NativeToolCallParser {
* Parse dynamic MCP tools (named mcp--serverName--toolName).
* These are generated dynamically by getMcpServerTools() and are returned
* as McpToolUse objects that preserve the original tool name.
- *
- * In native mode, MCP tools are NOT converted to use_mcp_tool - they keep
- * their original name so it appears correctly in API conversation history.
- * The use_mcp_tool wrapper is only used in XML mode.
*/
public static parseDynamicMcpTool(toolCall: { id: string; name: string; arguments: string }): McpToolUse | null {
try {
diff --git a/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts b/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts
deleted file mode 100644
index cb60c8744f..0000000000
--- a/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts
+++ /dev/null
@@ -1,392 +0,0 @@
-// npx vitest src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts
-
-import { AssistantMessageParser } from "../AssistantMessageParser"
-import { AssistantMessageContent } from "../parseAssistantMessage"
-import { TextContent, ToolUse } from "../../../shared/tools"
-
-/**
- * Helper to filter out empty text content blocks.
- */
-const isEmptyTextContent = (block: any) => block.type === "text" && (block as TextContent).content === ""
-
-/**
- * Helper to simulate streaming by feeding the parser deterministic "random"-sized chunks (1-10 chars).
- * Uses a seeded pseudo-random number generator for deterministic chunking.
- */
-
-// Simple linear congruential generator (LCG) for deterministic pseudo-random numbers
-function createSeededRandom(seed: number) {
- let state = seed
- return {
- next: () => {
- // LCG parameters from Numerical Recipes
- state = (state * 1664525 + 1013904223) % 0x100000000
- return state / 0x100000000
- },
- }
-}
-
-function streamChunks(
- parser: AssistantMessageParser,
- message: string,
-): ReturnType {
- let result: AssistantMessageContent[] = []
- let i = 0
- const rng = createSeededRandom(42) // Fixed seed for deterministic tests
- while (i < message.length) {
- // Deterministic chunk size between 1 and 10, but not exceeding message length
- const chunkSize = Math.min(message.length - i, Math.floor(rng.next() * 10) + 1)
- const chunk = message.slice(i, i + chunkSize)
- result = parser.processChunk(chunk)
- i += chunkSize
- }
- return result
-}
-
-describe("AssistantMessageParser (streaming)", () => {
- let parser: AssistantMessageParser
-
- beforeEach(() => {
- parser = new AssistantMessageParser()
- })
-
- describe("text content streaming", () => {
- it("should accumulate a simple text message chunk by chunk", () => {
- const message = "Hello, this is a test."
- const result = streamChunks(parser, message)
- expect(result).toHaveLength(1)
- expect(result[0]).toEqual({
- type: "text",
- content: message,
- partial: true,
- })
- })
-
- it("should accumulate multi-line text message chunk by chunk", () => {
- const message = "Line 1\nLine 2\nLine 3"
- const result = streamChunks(parser, message)
- expect(result).toHaveLength(1)
- expect(result[0]).toEqual({
- type: "text",
- content: message,
- partial: true,
- })
- })
- })
-
- describe("tool use streaming", () => {
- it("should parse a tool use with parameter, streamed char by char", () => {
- const message = "src/file.ts "
- const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block))
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should mark tool use as partial when not closed", () => {
- const message = "src/file.ts "
- const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block))
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.partial).toBe(true)
- })
-
- it("should handle a partial parameter in a tool use", () => {
- const message = "src/file"
- const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block))
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file")
- expect(toolUse.partial).toBe(true)
- })
-
- it("should handle tool use with multiple parameters streamed", () => {
- const message =
- "src/file.ts 10 20 "
- const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block))
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.params.start_line).toBe("10")
- expect(toolUse.params.end_line).toBe("20")
- expect(toolUse.partial).toBe(false)
- })
- })
-
- describe("mixed content streaming", () => {
- it("should parse text followed by a tool use, streamed", () => {
- const message = "Text before tool src/file.ts "
- const result = streamChunks(parser, message)
- expect(result).toHaveLength(2)
- const textContent = result[0] as TextContent
- expect(textContent.type).toBe("text")
- expect(textContent.content).toBe("Text before tool")
- expect(textContent.partial).toBe(false)
- const toolUse = result[1] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should parse a tool use followed by text, streamed", () => {
- const message = "src/file.ts Text after tool"
- const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block))
- expect(result).toHaveLength(2)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.partial).toBe(false)
- const textContent = result[1] as TextContent
- expect(textContent.type).toBe("text")
- expect(textContent.content).toBe("Text after tool")
- expect(textContent.partial).toBe(true)
- })
-
- it("should parse multiple tool uses separated by text, streamed", () => {
- const message =
- "First: file1.ts Second: file2.ts "
- const result = streamChunks(parser, message)
- expect(result).toHaveLength(4)
- expect(result[0].type).toBe("text")
- expect((result[0] as TextContent).content).toBe("First:")
- expect(result[1].type).toBe("tool_use")
- expect((result[1] as ToolUse).name).toBe("read_file")
- expect((result[1] as ToolUse).params.path).toBe("file1.ts")
- expect(result[2].type).toBe("text")
- expect((result[2] as TextContent).content).toBe("Second:")
- expect(result[3].type).toBe("tool_use")
- expect((result[3] as ToolUse).name).toBe("read_file")
- expect((result[3] as ToolUse).params.path).toBe("file2.ts")
- })
- })
-
- describe("special and edge cases", () => {
- it("should handle the write_to_file tool with content that contains closing tags", () => {
- const message = `src/file.ts
- function example() {
- // This has XML-like content:
- return true;
- }
- `
-
- const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("write_to_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.params.content).toContain("function example()")
- expect(toolUse.params.content).toContain("// This has XML-like content: ")
- expect(toolUse.params.content).toContain("return true;")
- expect(toolUse.partial).toBe(false)
- })
- it("should handle empty messages", () => {
- const message = ""
- const result = streamChunks(parser, message)
- expect(result).toHaveLength(0)
- })
-
- it("should handle malformed tool use tags as plain text", () => {
- const message = "This has a malformed tag "
- const result = streamChunks(parser, message)
- expect(result).toHaveLength(1)
- expect(result[0].type).toBe("text")
- expect((result[0] as TextContent).content).toBe(message)
- })
-
- it("should handle tool use with no parameters", () => {
- const message = " "
- const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block))
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("browser_action")
- expect(Object.keys(toolUse.params).length).toBe(0)
- expect(toolUse.partial).toBe(false)
- })
-
- it("should handle a tool use with a parameter containing XML-like content", () => {
- const message = ".*
src "
- const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block))
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("search_files")
- expect(toolUse.params.regex).toBe(".*
")
- expect(toolUse.params.path).toBe("src")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should handle consecutive tool uses without text in between", () => {
- const message = "file1.ts file2.ts "
- const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block))
- expect(result).toHaveLength(2)
- const toolUse1 = result[0] as ToolUse
- expect(toolUse1.type).toBe("tool_use")
- expect(toolUse1.name).toBe("read_file")
- expect(toolUse1.params.path).toBe("file1.ts")
- expect(toolUse1.partial).toBe(false)
- const toolUse2 = result[1] as ToolUse
- expect(toolUse2.type).toBe("tool_use")
- expect(toolUse2.name).toBe("read_file")
- expect(toolUse2.params.path).toBe("file2.ts")
- expect(toolUse2.partial).toBe(false)
- })
-
- it("should handle whitespace in parameters", () => {
- const message = " src/file.ts "
- const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block))
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should handle multi-line parameters", () => {
- const message = `file.ts
- line 1
- line 2
- line 3
- `
- const result = streamChunks(parser, message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("write_to_file")
- expect(toolUse.params.path).toBe("file.ts")
- expect(toolUse.params.content).toContain("line 1")
- expect(toolUse.params.content).toContain("line 2")
- expect(toolUse.params.content).toContain("line 3")
- expect(toolUse.partial).toBe(false)
- })
- it("should handle a complex message with multiple content types", () => {
- const message = `I'll help you with that task.
-
- src/index.ts
-
- Now let's modify the file:
-
- src/index.ts
- // Updated content
- console.log("Hello world");
-
-
- Let's run the code:
-
- node src/index.ts `
-
- const result = streamChunks(parser, message)
-
- expect(result).toHaveLength(6)
-
- // First text block
- expect(result[0].type).toBe("text")
- expect((result[0] as TextContent).content).toBe("I'll help you with that task.")
-
- // First tool use (read_file)
- expect(result[1].type).toBe("tool_use")
- expect((result[1] as ToolUse).name).toBe("read_file")
-
- // Second text block
- expect(result[2].type).toBe("text")
- expect((result[2] as TextContent).content).toContain("Now let's modify the file:")
-
- // Second tool use (write_to_file)
- expect(result[3].type).toBe("tool_use")
- expect((result[3] as ToolUse).name).toBe("write_to_file")
-
- // Third text block
- expect(result[4].type).toBe("text")
- expect((result[4] as TextContent).content).toContain("Let's run the code:")
-
- // Third tool use (execute_command)
- expect(result[5].type).toBe("tool_use")
- expect((result[5] as ToolUse).name).toBe("execute_command")
- })
- })
-
- describe("size limit handling", () => {
- it("should throw an error when MAX_ACCUMULATOR_SIZE is exceeded", () => {
- // Create a message that exceeds 1MB (MAX_ACCUMULATOR_SIZE)
- const largeMessage = "x".repeat(1024 * 1024 + 1) // 1MB + 1 byte
-
- expect(() => {
- parser.processChunk(largeMessage)
- }).toThrow("Assistant message exceeds maximum allowed size")
- })
-
- it("should gracefully handle a parameter that exceeds MAX_PARAM_LENGTH", () => {
- // Create a parameter value that exceeds 100KB (MAX_PARAM_LENGTH)
- const largeParamValue = "x".repeat(1024 * 100 + 1) // 100KB + 1 byte
- const message = `test.txt ${largeParamValue} After tool`
-
- // Process the message in chunks to simulate streaming
- let result: AssistantMessageContent[] = []
- let error: Error | null = null
-
- try {
- // Process the opening tags
- result = parser.processChunk("test.txt ")
-
- // Process the large parameter value in chunks
- const chunkSize = 1000
- for (let i = 0; i < largeParamValue.length; i += chunkSize) {
- const chunk = largeParamValue.slice(i, i + chunkSize)
- result = parser.processChunk(chunk)
- }
-
- // Process the closing tags and text after
- result = parser.processChunk(" After tool")
- } catch (e) {
- error = e as Error
- }
-
- // Should not throw an error
- expect(error).toBeNull()
-
- // Should have processed the content
- expect(result.length).toBeGreaterThan(0)
-
- // The tool use should exist but the content parameter should be reset/empty
- const toolUse = result.find((block) => block.type === "tool_use") as ToolUse
- expect(toolUse).toBeDefined()
- expect(toolUse.name).toBe("write_to_file")
- expect(toolUse.params.path).toBe("test.txt")
-
- // The text after the tool should still be parsed
- const textAfter = result.find(
- (block) => block.type === "text" && (block as TextContent).content.includes("After tool"),
- )
- expect(textAfter).toBeDefined()
- })
- })
-
- describe("finalizeContentBlocks", () => {
- it("should mark all partial blocks as complete", () => {
- const message = "src/file.ts"
- streamChunks(parser, message)
- let blocks = parser.getContentBlocks()
- // The block may already be partial or not, depending on chunking.
- // To ensure the test is robust, we only assert after finalizeContentBlocks.
- parser.finalizeContentBlocks()
- blocks = parser.getContentBlocks()
- expect(blocks[0].partial).toBe(false)
- })
- })
-})
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,60 +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"],
- },
- ],
- }),
- }
-
- const result = NativeToolCallParser.parseToolCall(toolCall)
-
- expect(result).not.toBeNull()
- expect(result?.type).toBe("tool_use")
- if (result?.type === "tool_use") {
- expect(result.nativeArgs).toBeDefined()
- const nativeArgs = result.nativeArgs as {
- 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 },
- ])
- }
- })
-
- it("should handle files without line_ranges", () => {
- const toolCall = {
- id: "toolu_123",
- name: "read_file" as const,
- arguments: JSON.stringify({
- files: [
- {
- path: "src/utils.ts",
- },
- ],
+ path: "src/core/task/Task.ts",
+ mode: "slice",
+ offset: 10,
+ limit: 20,
}),
}
@@ -94,32 +46,31 @@ 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
+ offset?: number
+ limit?: number
}
- 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/core/task/Task.ts")
+ expect(nativeArgs.mode).toBe("slice")
+ expect(nativeArgs.offset).toBe(10)
+ expect(nativeArgs.limit).toBe(20)
}
})
- it("should handle multiple files with different line_ranges", () => {
+ it("should parse indentation-mode params", () => {
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",
- },
- ],
+ path: "src/utils.ts",
+ mode: "indentation",
+ indentation: {
+ anchor_line: 123,
+ max_levels: 2,
+ include_siblings: true,
+ include_header: false,
+ },
}),
}
@@ -129,85 +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(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(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 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"],
- },
- ],
- }),
- }
-
- 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 }> }>
+ // 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" }],
+ }),
}
- expect(nativeArgs.files[0].lineRanges).toEqual([
- { start: 1, end: 50 },
- { start: 100, end: 200 },
- ])
- }
+
+ 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(1)
+ expect(nativeArgs.files[0].path).toBe("src/legacy/file.ts")
+ }
+ })
+
+ 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)
+
+ 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")
+ }
+ })
+
+ 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/__tests__/parseAssistantMessage.spec.ts b/src/core/assistant-message/__tests__/parseAssistantMessage.spec.ts
deleted file mode 100644
index 80d2502626..0000000000
--- a/src/core/assistant-message/__tests__/parseAssistantMessage.spec.ts
+++ /dev/null
@@ -1,338 +0,0 @@
-// npx vitest src/core/assistant-message/__tests__/parseAssistantMessage.spec.ts
-
-import { TextContent, ToolUse } from "../../../shared/tools"
-
-import { AssistantMessageContent, parseAssistantMessage as parseAssistantMessageV1 } from "../parseAssistantMessage"
-import { parseAssistantMessageV2 } from "../parseAssistantMessageV2"
-
-const isEmptyTextContent = (block: AssistantMessageContent) =>
- block.type === "text" && (block as TextContent).content === ""
-
-;[parseAssistantMessageV1, parseAssistantMessageV2].forEach((parser, index) => {
- describe(`parseAssistantMessageV${index + 1}`, () => {
- describe("text content parsing", () => {
- it("should parse a simple text message", () => {
- const message = "This is a simple text message"
- const result = parser(message)
-
- expect(result).toHaveLength(1)
- expect(result[0]).toEqual({
- type: "text",
- content: message,
- partial: true, // Text is always partial when it's the last content
- })
- })
-
- it("should parse a multi-line text message", () => {
- const message = "This is a multi-line\ntext message\nwith several lines"
- const result = parser(message)
-
- expect(result).toHaveLength(1)
- expect(result[0]).toEqual({
- type: "text",
- content: message,
- partial: true, // Text is always partial when it's the last content
- })
- })
-
- it("should mark text as partial when it's the last content in the message", () => {
- const message = "This is a partial text"
- const result = parser(message)
-
- expect(result).toHaveLength(1)
- expect(result[0]).toEqual({
- type: "text",
- content: message,
- partial: true,
- })
- })
- })
-
- describe("tool use parsing", () => {
- it("should parse a simple tool use", () => {
- const message = "src/file.ts "
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should parse a tool use with multiple parameters", () => {
- const message =
- "src/file.ts 10 20 "
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.params.start_line).toBe("10")
- expect(toolUse.params.end_line).toBe("20")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should mark tool use as partial when it's not closed", () => {
- const message = "src/file.ts "
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.partial).toBe(true)
- })
-
- it("should handle a partial parameter in a tool use", () => {
- const message = "src/file.ts"
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.partial).toBe(true)
- })
- })
-
- describe("mixed content parsing", () => {
- it("should parse text followed by a tool use", () => {
- const message = "Here's the file content: src/file.ts "
- const result = parser(message)
-
- expect(result).toHaveLength(2)
-
- const textContent = result[0] as TextContent
- expect(textContent.type).toBe("text")
- expect(textContent.content).toBe("Here's the file content:")
- expect(textContent.partial).toBe(false)
-
- const toolUse = result[1] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should parse a tool use followed by text", () => {
- const message = "src/file.ts Here's what I found in the file."
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(2)
-
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.partial).toBe(false)
-
- const textContent = result[1] as TextContent
- expect(textContent.type).toBe("text")
- expect(textContent.content).toBe("Here's what I found in the file.")
- expect(textContent.partial).toBe(true)
- })
-
- it("should parse multiple tool uses separated by text", () => {
- const message =
- "First file: src/file1.ts Second file: src/file2.ts "
- const result = parser(message)
-
- expect(result).toHaveLength(4)
-
- expect(result[0].type).toBe("text")
- expect((result[0] as TextContent).content).toBe("First file:")
-
- expect(result[1].type).toBe("tool_use")
- expect((result[1] as ToolUse).name).toBe("read_file")
- expect((result[1] as ToolUse).params.path).toBe("src/file1.ts")
-
- expect(result[2].type).toBe("text")
- expect((result[2] as TextContent).content).toBe("Second file:")
-
- expect(result[3].type).toBe("tool_use")
- expect((result[3] as ToolUse).name).toBe("read_file")
- expect((result[3] as ToolUse).params.path).toBe("src/file2.ts")
- })
- })
-
- describe("special cases", () => {
- it("should handle the write_to_file tool with content that contains closing tags", () => {
- const message = `src/file.ts
- function example() {
- // This has XML-like content:
- return true;
- }
- `
-
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("write_to_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.params.content).toContain("function example()")
- expect(toolUse.params.content).toContain("// This has XML-like content: ")
- expect(toolUse.params.content).toContain("return true;")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should handle empty messages", () => {
- const message = ""
- const result = parser(message)
-
- expect(result).toHaveLength(0)
- })
-
- it("should handle malformed tool use tags", () => {
- const message = "This has a malformed tag "
- const result = parser(message)
-
- expect(result).toHaveLength(1)
- expect(result[0].type).toBe("text")
- expect((result[0] as TextContent).content).toBe(message)
- })
-
- it("should handle tool use with no parameters", () => {
- const message = " "
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("browser_action")
- expect(Object.keys(toolUse.params).length).toBe(0)
- expect(toolUse.partial).toBe(false)
- })
-
- it("should handle nested tool tags that aren't actually nested", () => {
- const message =
- "echo 'test.txt ' "
-
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("execute_command")
- expect(toolUse.params.command).toBe("echo 'test.txt '")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should handle a tool use with a parameter containing XML-like content", () => {
- const message = ".*
src "
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("search_files")
- expect(toolUse.params.regex).toBe(".*
")
- expect(toolUse.params.path).toBe("src")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should handle consecutive tool uses without text in between", () => {
- const message =
- "file1.ts file2.ts "
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(2)
-
- const toolUse1 = result[0] as ToolUse
- expect(toolUse1.type).toBe("tool_use")
- expect(toolUse1.name).toBe("read_file")
- expect(toolUse1.params.path).toBe("file1.ts")
- expect(toolUse1.partial).toBe(false)
-
- const toolUse2 = result[1] as ToolUse
- expect(toolUse2.type).toBe("tool_use")
- expect(toolUse2.name).toBe("read_file")
- expect(toolUse2.params.path).toBe("file2.ts")
- expect(toolUse2.partial).toBe(false)
- })
-
- it("should handle whitespace in parameters", () => {
- const message = " src/file.ts "
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("read_file")
- expect(toolUse.params.path).toBe("src/file.ts")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should handle multi-line parameters", () => {
- const message = `file.ts
- line 1
- line 2
- line 3
- `
- const result = parser(message).filter((block) => !isEmptyTextContent(block))
-
- expect(result).toHaveLength(1)
- const toolUse = result[0] as ToolUse
- expect(toolUse.type).toBe("tool_use")
- expect(toolUse.name).toBe("write_to_file")
- expect(toolUse.params.path).toBe("file.ts")
- expect(toolUse.params.content).toContain("line 1")
- expect(toolUse.params.content).toContain("line 2")
- expect(toolUse.params.content).toContain("line 3")
- expect(toolUse.partial).toBe(false)
- })
-
- it("should handle a complex message with multiple content types", () => {
- const message = `I'll help you with that task.
-
- src/index.ts
-
- Now let's modify the file:
-
- src/index.ts
- // Updated content
- console.log("Hello world");
-
-
- Let's run the code:
-
- node src/index.ts `
-
- const result = parser(message)
-
- expect(result).toHaveLength(6)
-
- // First text block
- expect(result[0].type).toBe("text")
- expect((result[0] as TextContent).content).toBe("I'll help you with that task.")
-
- // First tool use (read_file)
- expect(result[1].type).toBe("tool_use")
- expect((result[1] as ToolUse).name).toBe("read_file")
-
- // Second text block
- expect(result[2].type).toBe("text")
- expect((result[2] as TextContent).content).toContain("Now let's modify the file:")
-
- // Second tool use (write_to_file)
- expect(result[3].type).toBe("tool_use")
- expect((result[3] as ToolUse).name).toBe("write_to_file")
-
- // Third text block
- expect(result[4].type).toBe("text")
- expect((result[4] as TextContent).content).toContain("Let's run the code:")
-
- // Third tool use (execute_command)
- expect(result[5].type).toBe("tool_use")
- expect((result[5] as ToolUse).name).toBe("execute_command")
- })
- })
- })
-})
diff --git a/src/core/assistant-message/__tests__/parseAssistantMessageBenchmark.ts b/src/core/assistant-message/__tests__/parseAssistantMessageBenchmark.ts
deleted file mode 100644
index a32b1173ce..0000000000
--- a/src/core/assistant-message/__tests__/parseAssistantMessageBenchmark.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unsafe-function-type */
-
-// node --expose-gc --import tsx src/core/assistant-message/__tests__/parseAssistantMessageBenchmark.ts
-
-import { performance } from "perf_hooks"
-import { parseAssistantMessage as parseAssistantMessageV1 } from "../parseAssistantMessage"
-import { parseAssistantMessageV2 } from "../parseAssistantMessageV2"
-
-const formatNumber = (num: number): string => {
- return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
-}
-
-const measureExecutionTime = (fn: Function, input: string, iterations: number = 1000): number => {
- for (let i = 0; i < 10; i++) {
- fn(input)
- }
-
- const start = performance.now()
-
- for (let i = 0; i < iterations; i++) {
- fn(input)
- }
-
- const end = performance.now()
- return (end - start) / iterations // Average time per iteration in ms.
-}
-
-const measureMemoryUsage = (
- fn: Function,
- input: string,
- iterations: number = 100,
-): { heapUsed: number; heapTotal: number } => {
- if (global.gc) {
- // Force garbage collection if available.
- global.gc()
- } else {
- console.warn("No garbage collection hook! Run with --expose-gc for more accurate memory measurements.")
- }
-
- const initialMemory = process.memoryUsage()
-
- for (let i = 0; i < iterations; i++) {
- fn(input)
- }
-
- const finalMemory = process.memoryUsage()
-
- return {
- heapUsed: (finalMemory.heapUsed - initialMemory.heapUsed) / iterations,
- heapTotal: (finalMemory.heapTotal - initialMemory.heapTotal) / iterations,
- }
-}
-
-const testCases = [
- {
- name: "Simple text message",
- input: "This is a simple text message without any tool uses.",
- },
- {
- name: "Message with a simple tool use",
- input: "Let's read a file: src/file.ts ",
- },
- {
- name: "Message with a complex tool use (write_to_file)",
- input: "src/file.ts \nfunction example() {\n // This has XML-like content: \n return true;\n}\n ",
- },
- {
- name: "Message with multiple tool uses",
- input: "First file: src/file1.ts \nSecond file: src/file2.ts \nLet's write a new file: src/file3.ts \nexport function newFunction() {\n return 'Hello world';\n}\n ",
- },
- {
- name: "Large message with repeated tool uses",
- input: Array(50)
- .fill(
- 'src/file.ts \noutput.ts console.log("hello"); ',
- )
- .join("\n"),
- },
-]
-
-const runBenchmark = () => {
- const maxNameLength = testCases.reduce((max, testCase) => Math.max(max, testCase.name.length), 0)
- const namePadding = maxNameLength + 2
-
- console.log(
- `| ${"Test Case".padEnd(namePadding)} | V1 Time (ms) | V2 Time (ms) | V1/V2 Ratio | V1 Heap (bytes) | V2 Heap (bytes) |`,
- )
- console.log(
- `| ${"-".repeat(namePadding)} | ------------ | ------------ | ----------- | ---------------- | ---------------- |`,
- )
-
- for (const testCase of testCases) {
- const v1Time = measureExecutionTime(parseAssistantMessageV1, testCase.input)
- const v2Time = measureExecutionTime(parseAssistantMessageV2, testCase.input)
- const timeRatio = v1Time / v2Time
-
- const v1Memory = measureMemoryUsage(parseAssistantMessageV1, testCase.input)
- const v2Memory = measureMemoryUsage(parseAssistantMessageV2, testCase.input)
-
- console.log(
- `| ${testCase.name.padEnd(namePadding)} | ` +
- `${v1Time.toFixed(4).padStart(12)} | ` +
- `${v2Time.toFixed(4).padStart(12)} | ` +
- `${timeRatio.toFixed(2).padStart(11)} | ` +
- `${formatNumber(Math.round(v1Memory.heapUsed)).padStart(16)} | ` +
- `${formatNumber(Math.round(v2Memory.heapUsed)).padStart(16)} |`,
- )
- }
-}
-
-runBenchmark()
diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts
index e90646fd9a..690861bb56 100644
--- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts
+++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts
@@ -7,6 +7,11 @@ import { presentAssistantMessage } from "../presentAssistantMessage"
vi.mock("../../task/Task")
vi.mock("../../tools/validateToolUse", () => ({
validateToolUse: vi.fn(),
+ isValidToolName: vi.fn((toolName: string) =>
+ ["read_file", "write_to_file", "ask_followup_question", "attempt_completion", "use_mcp_tool"].includes(
+ toolName,
+ ),
+ ),
}))
// Mock custom tool registry - must be done inline without external variable references
@@ -49,7 +54,6 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
didCompleteReadingStream: false,
didRejectTool: false,
didAlreadyUseTool: false,
- diffEnabled: false,
consecutiveMistakeCount: 0,
clineMessages: [],
api: {
@@ -116,39 +120,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
// Should record as "custom_tool", not "my_custom_tool"
expect(mockTask.recordToolUsage).toHaveBeenCalledWith("custom_tool")
- expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(
- mockTask.taskId,
- "custom_tool",
- "native",
- )
- })
-
- it("should record custom tool usage as 'custom_tool' in XML protocol", async () => {
- mockTask.assistantMessageContent = [
- {
- type: "tool_use",
- // No ID = XML protocol
- name: "my_custom_tool",
- params: { value: "test" },
- partial: false,
- },
- ]
-
- vi.mocked(customToolRegistry.has).mockReturnValue(true)
- vi.mocked(customToolRegistry.get).mockReturnValue({
- name: "my_custom_tool",
- description: "A custom tool",
- execute: vi.fn().mockResolvedValue("Custom tool result"),
- })
-
- await presentAssistantMessage(mockTask)
-
- expect(mockTask.recordToolUsage).toHaveBeenCalledWith("custom_tool")
- expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(
- mockTask.taskId,
- "custom_tool",
- "xml",
- )
+ expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "custom_tool")
})
})
@@ -201,11 +173,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
// Should record as "read_file", not "custom_tool"
expect(mockTask.recordToolUsage).toHaveBeenCalledWith("read_file")
- expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(
- mockTask.taskId,
- "read_file",
- "native",
- )
+ expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "read_file")
})
it("should record MCP tool usage as 'use_mcp_tool' (not custom_tool)", async () => {
@@ -247,11 +215,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
// Should record as "use_mcp_tool", not "custom_tool"
expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool")
- expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(
- mockTask.taskId,
- "use_mcp_tool",
- "native",
- )
+ expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "use_mcp_tool")
})
})
diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts
index 72ee430609..7316884984 100644
--- a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts
+++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts
@@ -4,12 +4,16 @@ import { describe, it, expect, beforeEach, vi } from "vitest"
import { Anthropic } from "@anthropic-ai/sdk"
import { presentAssistantMessage } from "../presentAssistantMessage"
import { Task } from "../../task/Task"
-import { TOOL_PROTOCOL } from "@roo-code/types"
// Mock dependencies
vi.mock("../../task/Task")
vi.mock("../../tools/validateToolUse", () => ({
validateToolUse: vi.fn(),
+ isValidToolName: vi.fn((toolName: string) =>
+ ["read_file", "write_to_file", "ask_followup_question", "attempt_completion", "use_mcp_tool"].includes(
+ toolName,
+ ),
+ ),
}))
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
@@ -20,7 +24,7 @@ vi.mock("@roo-code/telemetry", () => ({
},
}))
-describe("presentAssistantMessage - Image Handling in Native Tool Calls", () => {
+describe("presentAssistantMessage - Image Handling in Native Tool Calling", () => {
let mockTask: any
beforeEach(() => {
@@ -37,7 +41,6 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
didCompleteReadingStream: false,
didRejectTool: false,
didAlreadyUseTool: false,
- diffEnabled: false,
consecutiveMistakeCount: 0,
api: {
getModel: () => ({ id: "test-model", info: {} }),
@@ -74,15 +77,16 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
})
})
- it("should preserve images in tool_result for native protocol", async () => {
- // Set up a tool_use block with an ID (indicates native protocol)
+ it("should preserve images in tool_result for native tool calling", async () => {
+ // Set up a tool_use block with an ID (indicates native tool calling)
const toolCallId = "tool_call_123"
mockTask.assistantMessageContent = [
{
type: "tool_use",
- id: toolCallId, // ID indicates native protocol
+ id: toolCallId, // ID indicates native tool calling
name: "ask_followup_question",
params: { question: "What do you see?" },
+ nativeArgs: { question: "What do you see?", follow_up: [] },
},
]
@@ -116,7 +120,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
expect(toolResult).toBeDefined()
expect(toolResult.tool_use_id).toBe(toolCallId)
- // For native protocol, tool_result content should be a string (text only)
+ // For native tool calling, tool_result content should be a string (text only)
expect(typeof toolResult.content).toBe("string")
expect(toolResult.content).toContain("I see a cat")
@@ -126,7 +130,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
expect(imageBlocks[0].source.data).toBe("base64ImageData")
})
- it("should convert to string when no images are present (native protocol)", async () => {
+ it("should convert to string when no images are present (native tool calling)", async () => {
// Set up a tool_use block with an ID (indicates native protocol)
const toolCallId = "tool_call_456"
mockTask.assistantMessageContent = [
@@ -135,6 +139,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
id: toolCallId,
name: "ask_followup_question",
params: { question: "What is your name?" },
+ nativeArgs: { question: "What is your name?", follow_up: [] },
},
]
@@ -157,12 +162,11 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
expect(typeof toolResult.content).toBe("string")
})
- it("should preserve images in content array for XML protocol (existing behavior)", async () => {
- // Set up a tool_use block WITHOUT an ID (indicates XML protocol)
+ it("should fail fast when tool_use is missing id (legacy/XML-style tool call)", async () => {
+ // tool_use without an id is treated as legacy/XML-style tool call and must be rejected.
mockTask.assistantMessageContent = [
{
type: "tool_use",
- // No ID = XML protocol
name: "ask_followup_question",
params: { question: "What do you see?" },
},
@@ -176,14 +180,13 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
await presentAssistantMessage(mockTask)
- // For XML protocol, content is added as separate blocks
- // Check that both text and image blocks were added
- const hasTextBlock = mockTask.userMessageContent.some((item: any) => item.type === "text")
- const hasImageBlock = mockTask.userMessageContent.some((item: any) => item.type === "image")
-
- expect(hasTextBlock).toBe(true)
- // XML protocol preserves images as separate blocks in userMessageContent
- expect(hasImageBlock).toBe(true)
+ const textBlocks = mockTask.userMessageContent.filter((item: any) => item.type === "text")
+ expect(textBlocks.length).toBeGreaterThan(0)
+ expect(textBlocks.some((b: any) => String(b.text).includes("XML tool calls are no longer supported"))).toBe(
+ true,
+ )
+ // Should not proceed to execute tool or add images as tool output.
+ expect(mockTask.userMessageContent.some((item: any) => item.type === "image")).toBe(false)
})
it("should handle empty tool result gracefully", async () => {
@@ -216,7 +219,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
})
describe("Multiple tool calls handling", () => {
- it("should send tool_result with is_error for skipped tools in native protocol when didRejectTool is true", async () => {
+ it("should send tool_result with is_error for skipped tools in native tool calling when didRejectTool is true", async () => {
// Simulate multiple tool calls with native protocol (all have IDs)
const toolCallId1 = "tool_call_001"
const toolCallId2 = "tool_call_002"
@@ -261,63 +264,15 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
expect(textBlocks.length).toBe(0)
})
- it("should send tool_result with is_error for skipped tools in native protocol when didAlreadyUseTool is true", async () => {
- // Simulate multiple tool calls with native protocol
- const toolCallId1 = "tool_call_003"
- const toolCallId2 = "tool_call_004"
-
+ it("should reject subsequent tool calls when a legacy/XML-style tool call is encountered", async () => {
mockTask.assistantMessageContent = [
{
type: "tool_use",
- id: toolCallId1,
name: "read_file",
params: { path: "test.txt" },
},
{
type: "tool_use",
- id: toolCallId2,
- name: "write_to_file",
- params: { path: "output.txt", content: "test" },
- },
- ]
-
- // First tool was already used
- mockTask.didAlreadyUseTool = true
-
- // Process the second tool (should be skipped)
- mockTask.currentStreamingContentIndex = 1
- await presentAssistantMessage(mockTask)
-
- // Find the tool_result for the second tool
- const toolResult = mockTask.userMessageContent.find(
- (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId2,
- )
-
- // Verify that a tool_result block was created (not a text block)
- expect(toolResult).toBeDefined()
- expect(toolResult.tool_use_id).toBe(toolCallId2)
- expect(toolResult.is_error).toBe(true)
- expect(toolResult.content).toContain("was not executed because a tool has already been used")
-
- // Ensure no text blocks were added for this rejection
- const textBlocks = mockTask.userMessageContent.filter(
- (item: any) => item.type === "text" && item.text.includes("was not executed because"),
- )
- expect(textBlocks.length).toBe(0)
- })
-
- it("should send text blocks for skipped tools in XML protocol (no tool IDs)", async () => {
- // Simulate multiple tool calls with XML protocol (no IDs)
- mockTask.assistantMessageContent = [
- {
- type: "tool_use",
- // No ID = XML protocol
- name: "read_file",
- params: { path: "test.txt" },
- },
- {
- type: "tool_use",
- // No ID = XML protocol
name: "write_to_file",
params: { path: "output.txt", content: "test" },
},
@@ -330,18 +285,15 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
mockTask.currentStreamingContentIndex = 1
await presentAssistantMessage(mockTask)
- // For XML protocol, should add text block (not tool_result)
- const textBlocks = mockTask.userMessageContent.filter(
- (item: any) => item.type === "text" && item.text.includes("due to user rejecting"),
+ const textBlocks = mockTask.userMessageContent.filter((item: any) => item.type === "text")
+ expect(textBlocks.some((b: any) => String(b.text).includes("XML tool calls are no longer supported"))).toBe(
+ true,
)
- expect(textBlocks.length).toBeGreaterThan(0)
-
// Ensure no tool_result blocks were added
- const toolResults = mockTask.userMessageContent.filter((item: any) => item.type === "tool_result")
- expect(toolResults.length).toBe(0)
+ expect(mockTask.userMessageContent.some((item: any) => item.type === "tool_result")).toBe(false)
})
- it("should handle partial tool blocks when didRejectTool is true in native protocol", async () => {
+ it("should handle partial tool blocks when didRejectTool is true in native tool calling", async () => {
const toolCallId = "tool_call_005"
mockTask.assistantMessageContent = [
diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts
index d4ae2764a0..15a1e2d867 100644
--- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts
+++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts
@@ -7,6 +7,7 @@ import { presentAssistantMessage } from "../presentAssistantMessage"
vi.mock("../../task/Task")
vi.mock("../../tools/validateToolUse", () => ({
validateToolUse: vi.fn(),
+ isValidToolName: vi.fn(() => false),
}))
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
@@ -34,7 +35,6 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
didCompleteReadingStream: false,
didRejectTool: false,
didAlreadyUseTool: false,
- diffEnabled: false,
consecutiveMistakeCount: 0,
clineMessages: [],
api: {
@@ -74,12 +74,12 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
})
it("should return error for unknown tool in native protocol", async () => {
- // Set up a tool_use block with an unknown tool name and an ID (native protocol)
+ // Set up a tool_use block with an unknown tool name and an ID (native tool calling)
const toolCallId = "tool_call_unknown_123"
mockTask.assistantMessageContent = [
{
type: "tool_use",
- id: toolCallId, // ID indicates native protocol
+ id: toolCallId, // ID indicates native tool calling
name: "nonexistent_tool",
params: { some: "param" },
partial: false,
@@ -114,12 +114,11 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
expect(mockTask.say).toHaveBeenCalledWith("error", "unknownToolError")
})
- it("should return error for unknown tool in XML protocol", async () => {
- // Set up a tool_use block with an unknown tool name WITHOUT an ID (XML protocol)
+ it("should fail fast when tool_use is missing id (legacy/XML-style tool call)", async () => {
+ // tool_use without an id is treated as legacy/XML-style tool call and must be rejected.
mockTask.assistantMessageContent = [
{
type: "tool_use",
- // No ID = XML protocol
name: "fake_tool_that_does_not_exist",
params: { param1: "value1" },
partial: false,
@@ -129,16 +128,12 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
// Execute presentAssistantMessage
await presentAssistantMessage(mockTask)
- // For XML protocol, error is pushed as text blocks
+ // Should not execute tool; should surface a clear error message.
const textBlocks = mockTask.userMessageContent.filter((item: any) => item.type === "text")
-
- // There should be text blocks with error message
expect(textBlocks.length).toBeGreaterThan(0)
- const hasErrorMessage = textBlocks.some(
- (block: any) =>
- block.text?.includes("fake_tool_that_does_not_exist") && block.text?.includes("does not exist"),
+ expect(textBlocks.some((b: any) => String(b.text).includes("XML tool calls are no longer supported"))).toBe(
+ true,
)
- expect(hasErrorMessage).toBe(true)
// Verify consecutiveMistakeCount was incremented
expect(mockTask.consecutiveMistakeCount).toBe(1)
@@ -146,17 +141,17 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
// Verify recordToolError was called
expect(mockTask.recordToolError).toHaveBeenCalled()
- // Verify error message was shown to user (uses i18n key)
- expect(mockTask.say).toHaveBeenCalledWith("error", "unknownToolError")
+ // Verify error message was shown to user
+ expect(mockTask.say).toHaveBeenCalledWith("error", expect.anything())
})
- it("should handle unknown tool without freezing (native protocol)", async () => {
+ it("should handle unknown tool without freezing (native tool calling)", async () => {
// This test ensures the extension doesn't freeze when an unknown tool is called
const toolCallId = "tool_call_freeze_test"
mockTask.assistantMessageContent = [
{
type: "tool_use",
- id: toolCallId, // Native protocol
+ id: toolCallId, // Native tool calling
name: "this_tool_definitely_does_not_exist",
params: {},
partial: false,
@@ -222,32 +217,6 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
expect(mockTask.userMessageContentReady).toBe(true)
})
- it("should still work with didAlreadyUseTool flag for unknown tool", async () => {
- const toolCallId = "tool_call_already_used_test"
- mockTask.assistantMessageContent = [
- {
- type: "tool_use",
- id: toolCallId,
- name: "unknown_tool",
- params: {},
- partial: false,
- },
- ]
-
- mockTask.didAlreadyUseTool = true
-
- await presentAssistantMessage(mockTask)
-
- // When didAlreadyUseTool is true, should send error tool_result
- const toolResult = mockTask.userMessageContent.find(
- (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId,
- )
-
- expect(toolResult).toBeDefined()
- expect(toolResult.is_error).toBe(true)
- expect(toolResult.content).toContain("was not executed because a tool has already been used")
- })
-
it("should still work with didRejectTool flag for unknown tool", async () => {
const toolCallId = "tool_call_rejected_test"
mockTask.assistantMessageContent = [
diff --git a/src/core/assistant-message/index.ts b/src/core/assistant-message/index.ts
index 72201b7722..107424fc50 100644
--- a/src/core/assistant-message/index.ts
+++ b/src/core/assistant-message/index.ts
@@ -1,2 +1,2 @@
-export { type AssistantMessageContent, parseAssistantMessage } from "./parseAssistantMessage"
+export type { AssistantMessageContent } from "./types"
export { presentAssistantMessage } from "./presentAssistantMessage"
diff --git a/src/core/assistant-message/parseAssistantMessage.ts b/src/core/assistant-message/parseAssistantMessage.ts
deleted file mode 100644
index e07b8cc3db..0000000000
--- a/src/core/assistant-message/parseAssistantMessage.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { type ToolName, toolNames } from "@roo-code/types"
-
-import { TextContent, ToolUse, McpToolUse, ToolParamName, toolParamNames } from "../../shared/tools"
-
-export type AssistantMessageContent = TextContent | ToolUse | McpToolUse
-
-export function parseAssistantMessage(assistantMessage: string): AssistantMessageContent[] {
- let contentBlocks: AssistantMessageContent[] = []
- let currentTextContent: TextContent | undefined = undefined
- let currentTextContentStartIndex = 0
- let currentToolUse: ToolUse | undefined = undefined
- let currentToolUseStartIndex = 0
- let currentParamName: ToolParamName | undefined = undefined
- let currentParamValueStartIndex = 0
- let accumulator = ""
-
- for (let i = 0; i < assistantMessage.length; i++) {
- const char = assistantMessage[i]
- accumulator += char
-
- // There should not be a param without a tool use.
- if (currentToolUse && currentParamName) {
- const currentParamValue = accumulator.slice(currentParamValueStartIndex)
- const paramClosingTag = `${currentParamName}>`
- if (currentParamValue.endsWith(paramClosingTag)) {
- // End of param value.
- // Don't trim content parameters to preserve newlines, but strip first and last newline only
- const paramValue = currentParamValue.slice(0, -paramClosingTag.length)
- currentToolUse.params[currentParamName] =
- currentParamName === "content"
- ? paramValue.replace(/^\n/, "").replace(/\n$/, "")
- : paramValue.trim()
- currentParamName = undefined
- continue
- } else {
- // Partial param value is accumulating.
- continue
- }
- }
-
- // No currentParamName.
-
- if (currentToolUse) {
- const currentToolValue = accumulator.slice(currentToolUseStartIndex)
- const toolUseClosingTag = `${currentToolUse.name}>`
- if (currentToolValue.endsWith(toolUseClosingTag)) {
- // End of a tool use.
- currentToolUse.partial = false
- contentBlocks.push(currentToolUse)
- currentToolUse = undefined
- continue
- } else {
- const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`)
- for (const paramOpeningTag of possibleParamOpeningTags) {
- if (accumulator.endsWith(paramOpeningTag)) {
- // Start of a new parameter.
- currentParamName = paramOpeningTag.slice(1, -1) as ToolParamName
- currentParamValueStartIndex = accumulator.length
- break
- }
- }
-
- // There's no current param, and not starting a new param.
-
- // Special case for write_to_file where file contents could
- // contain the closing tag, in which case the param would have
- // closed and we end up with the rest of the file contents here.
- // To work around this, we get the string between the starting
- // content tag and the LAST content tag.
- const contentParamName: ToolParamName = "content"
-
- if (currentToolUse.name === "write_to_file" && accumulator.endsWith(`${contentParamName}>`)) {
- const toolContent = accumulator.slice(currentToolUseStartIndex)
- const contentStartTag = `<${contentParamName}>`
- const contentEndTag = `${contentParamName}>`
- const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length
- const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
-
- if (contentStartIndex !== -1 && contentEndIndex !== -1 && contentEndIndex > contentStartIndex) {
- // Don't trim content to preserve newlines, but strip first and last newline only
- currentToolUse.params[contentParamName] = toolContent
- .slice(contentStartIndex, contentEndIndex)
- .replace(/^\n/, "")
- .replace(/\n$/, "")
- }
- }
-
- // Partial tool value is accumulating.
- continue
- }
- }
-
- // No currentToolUse.
-
- let didStartToolUse = false
- const possibleToolUseOpeningTags = toolNames.map((name) => `<${name}>`)
-
- for (const toolUseOpeningTag of possibleToolUseOpeningTags) {
- if (accumulator.endsWith(toolUseOpeningTag)) {
- // Start of a new tool use.
- currentToolUse = {
- type: "tool_use",
- name: toolUseOpeningTag.slice(1, -1) as ToolName,
- params: {},
- partial: true,
- }
-
- currentToolUseStartIndex = accumulator.length
-
- // This also indicates the end of the current text content.
- if (currentTextContent) {
- currentTextContent.partial = false
-
- // Remove the partially accumulated tool use tag from the
- // end of text (()
- const toolParamOpenTags = new Map()
-
- for (const name of toolNames) {
- toolUseOpenTags.set(`<${name}>`, name)
- }
-
- for (const name of toolParamNames) {
- toolParamOpenTags.set(`<${name}>`, name)
- }
-
- const len = assistantMessage.length
-
- for (let i = 0; i < len; i++) {
- const currentCharIndex = i
-
- // Parsing a tool parameter
- if (currentToolUse && currentParamName) {
- const closeTag = `${currentParamName}>`
- // Check if the string *ending* at index `i` matches the closing tag
- if (
- currentCharIndex >= closeTag.length - 1 &&
- assistantMessage.startsWith(
- closeTag,
- currentCharIndex - closeTag.length + 1, // Start checking from potential start of tag.
- )
- ) {
- // Found the closing tag for the parameter.
- const value = assistantMessage.slice(
- currentParamValueStart, // Start after the opening tag.
- currentCharIndex - closeTag.length + 1, // End before the closing tag.
- )
- // Don't trim content parameters to preserve newlines, but strip first and last newline only
- currentToolUse.params[currentParamName] =
- currentParamName === "content" ? value.replace(/^\n/, "").replace(/\n$/, "") : value.trim()
- currentParamName = undefined // Go back to parsing tool content.
- // We don't continue loop here, need to check for tool close or other params at index i.
- } else {
- continue // Still inside param value, move to next char.
- }
- }
-
- // Parsing a tool use (but not a specific parameter).
- if (currentToolUse && !currentParamName) {
- // Ensure we are not inside a parameter already.
- // Check if starting a new parameter.
- let startedNewParam = false
-
- for (const [tag, paramName] of toolParamOpenTags.entries()) {
- if (
- currentCharIndex >= tag.length - 1 &&
- assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)
- ) {
- currentParamName = paramName
- currentParamValueStart = currentCharIndex + 1 // Value starts after the tag.
- startedNewParam = true
- break
- }
- }
-
- if (startedNewParam) {
- continue // Handled start of param, move to next char.
- }
-
- // Check if closing the current tool use.
- const toolCloseTag = `${currentToolUse.name}>`
-
- if (
- currentCharIndex >= toolCloseTag.length - 1 &&
- assistantMessage.startsWith(toolCloseTag, currentCharIndex - toolCloseTag.length + 1)
- ) {
- // End of the tool use found.
- // Special handling for content params *before* finalizing the
- // tool.
- const toolContentSlice = assistantMessage.slice(
- currentToolUseStart, // From after the tool opening tag.
- currentCharIndex - toolCloseTag.length + 1, // To before the tool closing tag.
- )
-
- // Check if content parameter needs special handling
- // (write_to_file/new_rule).
- // This check is important if the closing tag was
- // missed by the parameter parsing logic (e.g., if content is
- // empty or parsing logic prioritizes tool close).
- const contentParamName: ToolParamName = "content"
- if (
- currentToolUse.name === "write_to_file" /* || currentToolUse.name === "new_rule" */ &&
- // !(contentParamName in currentToolUse.params) && // Only if not already parsed.
- toolContentSlice.includes(`<${contentParamName}>`) // Check if tag exists.
- ) {
- const contentStartTag = `<${contentParamName}>`
- const contentEndTag = `${contentParamName}>`
- const contentStart = toolContentSlice.indexOf(contentStartTag)
-
- // Use `lastIndexOf` for robustness against nested tags.
- const contentEnd = toolContentSlice.lastIndexOf(contentEndTag)
-
- if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart) {
- // Don't trim content to preserve newlines, but strip first and last newline only
- const contentValue = toolContentSlice
- .slice(contentStart + contentStartTag.length, contentEnd)
- .replace(/^\n/, "")
- .replace(/\n$/, "")
- currentToolUse.params[contentParamName] = contentValue
- }
- }
-
- currentToolUse.partial = false // Mark as complete.
- contentBlocks.push(currentToolUse)
- currentToolUse = undefined // Reset state.
- currentTextContentStart = currentCharIndex + 1 // Potential text starts after this tag.
- continue // Move to next char.
- }
-
- // If not starting a param and not closing the tool, continue
- // accumulating tool content implicitly.
- continue
- }
-
- // Parsing text / looking for tool start.
- if (!currentToolUse) {
- // Check if starting a new tool use.
- let startedNewTool = false
-
- for (const [tag, toolName] of toolUseOpenTags.entries()) {
- if (
- currentCharIndex >= tag.length - 1 &&
- assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)
- ) {
- // End current text block if one was active.
- if (currentTextContent) {
- currentTextContent.content = assistantMessage
- .slice(
- currentTextContentStart, // From where text started.
- currentCharIndex - tag.length + 1, // To before the tool tag starts.
- )
- .trim()
-
- currentTextContent.partial = false // Ended because tool started.
-
- if (currentTextContent.content.length > 0) {
- contentBlocks.push(currentTextContent)
- }
-
- currentTextContent = undefined
- } else {
- // Check for any text between the last block and this tag.
- const potentialText = assistantMessage
- .slice(
- currentTextContentStart, // From where text *might* have started.
- currentCharIndex - tag.length + 1, // To before the tool tag starts.
- )
- .trim()
-
- if (potentialText.length > 0) {
- contentBlocks.push({
- type: "text",
- content: potentialText,
- partial: false,
- })
- }
- }
-
- // Start the new tool use.
- currentToolUse = {
- type: "tool_use",
- name: toolName,
- params: {},
- partial: true, // Assume partial until closing tag is found.
- }
-
- currentToolUseStart = currentCharIndex + 1 // Tool content starts after the opening tag.
- startedNewTool = true
-
- break
- }
- }
-
- if (startedNewTool) {
- continue // Handled start of tool, move to next char.
- }
-
- // If not starting a tool, it must be text content.
- if (!currentTextContent) {
- // Start a new text block if we aren't already in one.
- currentTextContentStart = currentCharIndex // Text starts at the current character.
-
- // Check if the current char is the start of potential text *immediately* after a tag.
- // This needs the previous state - simpler to let slicing handle it later.
- // Resetting start index accurately is key.
- // It should be the index *after* the last processed tag.
- // The logic managing currentTextContentStart after closing tags handles this.
- currentTextContent = {
- type: "text",
- content: "", // Will be determined by slicing at the end or when a tool starts
- partial: true,
- }
- }
- // Continue accumulating text implicitly; content is extracted later.
- }
- }
-
- // Finalize any open parameter within an open tool use.
- if (currentToolUse && currentParamName) {
- const value = assistantMessage.slice(currentParamValueStart) // From param start to end of string.
- // Don't trim content parameters to preserve newlines, but strip first and last newline only
- currentToolUse.params[currentParamName] =
- currentParamName === "content" ? value.replace(/^\n/, "").replace(/\n$/, "") : value.trim()
- // Tool use remains partial.
- }
-
- // Finalize any open tool use (which might contain the finalized partial param).
- if (currentToolUse) {
- // Tool use is partial because the loop finished before its closing tag.
- contentBlocks.push(currentToolUse)
- }
- // Finalize any trailing text content.
- // Only possible if a tool use wasn't open at the very end.
- else if (currentTextContent) {
- currentTextContent.content = assistantMessage
- .slice(currentTextContentStart) // From text start to end of string.
- .trim()
-
- // Text is partial because the loop finished.
- if (currentTextContent.content.length > 0) {
- contentBlocks.push(currentTextContent)
- }
- }
-
- return contentBlocks
-}
diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts
index 693327a022..c22c369b42 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"
@@ -10,17 +10,14 @@ import { t } from "../../i18n"
import { defaultModeSlug, getModeBySlug } from "../../shared/modes"
import type { ToolParamName, ToolResponse, ToolUse, McpToolUse } from "../../shared/tools"
-import { experiments, EXPERIMENT_IDS } from "../../shared/experiments"
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 { TOOL_PROTOCOL } from "@roo-code/types"
+import { readCommandOutputTool } from "../tools/ReadCommandOutputTool"
import { writeToFileTool } from "../tools/WriteToFileTool"
-import { applyDiffTool } from "../tools/MultiApplyDiffTool"
import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool"
import { searchReplaceTool } from "../tools/SearchReplaceTool"
import { editFileTool } from "../tools/EditFileTool"
@@ -36,12 +33,14 @@ 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 { validateToolUse } from "../tools/validateToolUse"
+import { isValidToolName, validateToolUse } from "../tools/validateToolUse"
import { codebaseSearchTool } from "../tools/CodebaseSearchTool"
import { formatResponse } from "../prompts/responses"
+import { sanitizeToolUseId } from "../../utils/tool-id"
/**
* Processes and presents assistant message content to the user interface.
@@ -120,22 +119,7 @@ export async function presentAssistantMessage(cline: Task) {
if (toolCallId) {
cline.pushToolResultToUserContent({
type: "tool_result",
- tool_use_id: toolCallId,
- content: errorMessage,
- is_error: true,
- })
- }
- break
- }
-
- if (cline.didAlreadyUseTool) {
- const toolCallId = mcpBlock.id
- const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.`
-
- if (toolCallId) {
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
+ tool_use_id: sanitizeToolUseId(toolCallId),
content: errorMessage,
is_error: true,
})
@@ -146,7 +130,6 @@ export async function presentAssistantMessage(cline: Task) {
// Track if we've already pushed a tool result
let hasToolResult = false
const toolCallId = mcpBlock.id
- const toolProtocol = TOOL_PROTOCOL.NATIVE // MCP tools in native mode always use native protocol
// Store approval feedback to merge into tool result (GitHub #10465)
let approvalFeedback: { text: string; images?: string[] } | undefined
@@ -174,7 +157,7 @@ export async function presentAssistantMessage(cline: Task) {
// Merge approval feedback into tool result (GitHub #10465)
if (approvalFeedback) {
- const feedbackText = formatResponse.toolApprovedWithFeedback(approvalFeedback.text, toolProtocol)
+ const feedbackText = formatResponse.toolApprovedWithFeedback(approvalFeedback.text)
resultContent = `${feedbackText}\n\n${resultContent}`
// Add feedback images to the image blocks
@@ -187,7 +170,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,
})
@@ -197,7 +180,6 @@ export async function presentAssistantMessage(cline: Task) {
}
hasToolResult = true
- cline.didAlreadyUseTool = true
}
const toolDescription = () => `[mcp_tool: ${mcpBlock.serverName}/${mcpBlock.toolName}]`
@@ -219,14 +201,9 @@ export async function presentAssistantMessage(cline: Task) {
if (response !== "yesButtonClicked") {
if (text) {
await cline.say("user_feedback", text, images)
- pushToolResult(
- formatResponse.toolResult(
- formatResponse.toolDeniedWithFeedback(text, toolProtocol),
- images,
- ),
- )
+ pushToolResult(formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images))
} else {
- pushToolResult(formatResponse.toolDenied(toolProtocol))
+ pushToolResult(formatResponse.toolDenied())
}
cline.didRejectTool = true
return false
@@ -254,12 +231,12 @@ export async function presentAssistantMessage(cline: Task) {
"error",
`Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`,
)
- pushToolResult(formatResponse.toolError(errorString, toolProtocol))
+ pushToolResult(formatResponse.toolError(errorString))
}
if (!mcpBlock.partial) {
cline.recordToolUsage("use_mcp_tool") // Record as use_mcp_tool for analytics
- TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool", toolProtocol)
+ TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool")
}
// Resolve sanitized server name back to original server name
@@ -297,8 +274,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag: (tag, text) => text || "",
- toolProtocol,
})
break
}
@@ -313,58 +288,20 @@ export async function presentAssistantMessage(cline: Task) {
// Have to do this for partial and complete since sending
// content in thinking tags to markdown renderer will
// automatically be removed.
- // Remove end substrings of (with optional line break
- // after) and (with optional line break before).
- // - Needs to be separate since we dont want to remove the line
- // break before the first tag.
- // - Needs to happen before the xml parsing below.
+ // Strip any streamed tags from text output.
content = content.replace(/\s?/g, "")
content = content.replace(/\s?<\/thinking>/g, "")
- // Remove partial XML tag at the very end of the content (for
- // tool use and thinking tags), Prevents scrollview from
- // jumping when tags are automatically removed.
- const lastOpenBracketIndex = content.lastIndexOf("<")
-
- if (lastOpenBracketIndex !== -1) {
- const possibleTag = content.slice(lastOpenBracketIndex)
-
- // Check if there's a '>' after the last '<' (i.e., if the
- // tag is complete) (complete thinking and tool tags will
- // have been removed by now.)
- const hasCloseBracket = possibleTag.includes(">")
-
- if (!hasCloseBracket) {
- // Extract the potential tag name.
- let tagContent: string
-
- if (possibleTag.startsWith("")) {
- tagContent = possibleTag.slice(2).trim()
- } else {
- tagContent = possibleTag.slice(1).trim()
- }
-
- // Check if tagContent is likely an incomplete tag name
- // (letters and underscores only).
- const isLikelyTagName = /^[a-zA-Z_]+$/.test(tagContent)
-
- // Preemptively remove < or to keep from these
- // artifacts showing up in chat (also handles closing
- // thinking tags).
- const isOpeningOrClosing = possibleTag === "<" || possibleTag === ""
-
- // If the tag is incomplete and at the end, remove it
- // from the content.
- if (isOpeningOrClosing || isLikelyTagName) {
- content = content.slice(0, lastOpenBracketIndex).trim()
- }
- }
+ // Tool calling is native-only. If the model emits XML-style tool tags in a text block,
+ // fail fast with a clear error.
+ if (containsXmlToolMarkup(content)) {
+ const errorMessage =
+ "XML tool calls are no longer supported. Remove any XML tool markup (e.g. ... ) and use native tool calling instead."
+ cline.consecutiveMistakeCount++
+ await cline.say("error", errorMessage)
+ cline.userMessageContent.push({ type: "text", text: errorMessage })
+ cline.didAlreadyUseTool = true
+ break
}
}
@@ -372,6 +309,30 @@ export async function presentAssistantMessage(cline: Task) {
break
}
case "tool_use": {
+ // Native tool calling is the only supported tool calling mechanism.
+ // A tool_use block without an id is invalid and cannot be executed.
+ const toolCallId = (block as any).id as string | undefined
+ if (!toolCallId) {
+ const errorMessage =
+ "Invalid tool call: missing tool_use.id. XML tool calls are no longer supported. Remove any XML tool markup (e.g. ... ) and use native tool calling instead."
+ // Record a tool error for visibility/telemetry. Use the reported tool name if present.
+ try {
+ if (
+ typeof (cline as any).recordToolError === "function" &&
+ typeof (block as any).name === "string"
+ ) {
+ ;(cline as any).recordToolError((block as any).name as ToolName, errorMessage)
+ }
+ } catch {
+ // Best-effort only
+ }
+ cline.consecutiveMistakeCount++
+ await cline.say("error", errorMessage)
+ cline.userMessageContent.push({ type: "text", text: errorMessage })
+ cline.didAlreadyUseTool = true
+ break
+ }
+
// Fetch state early so it's available for toolDescription and validation
const state = await cline.providerRef.deref()?.getState()
const { mode, customModes, experiments: stateExperiments } = state ?? {}
@@ -387,29 +348,11 @@ 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":
- // Handle both legacy format and new multi-file format
- if (block.params.path) {
- return `[${block.name} for '${block.params.path}']`
- } else if (block.params.args) {
- // Try to extract first file path from args for display
- const match = block.params.args.match(/.*?([^<]+)<\/path>/s)
- if (match) {
- const firstPath = match[1]
- // Check if there are multiple files
- const fileCount = (block.params.args.match(//g) || []).length
- if (fileCount > 1) {
- return `[${block.name} for '${firstPath}' and ${fileCount - 1} more file${fileCount > 2 ? "s" : ""}]`
- } else {
- return `[${block.name} for '${firstPath}']`
- }
- }
- }
- return `[${block.name}]`
+ // Native-only: tool args are structured (no XML payloads).
+ return block.params?.path ? `[${block.name} for '${block.params.path}']` : `[${block.name}]`
case "search_files":
return `[${block.name} for '${block.params.regex}'${
block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
@@ -436,8 +379,10 @@ export async function presentAssistantMessage(cline: Task) {
return `[${block.name}]`
case "switch_mode":
return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]`
- case "codebase_search": // Add case for the new tool
+ case "codebase_search":
return `[${block.name} for '${block.params.query}']`
+ case "read_command_output":
+ return `[${block.name} for '${block.params.artifact_id}']`
case "update_todo_list":
return `[${block.name}]`
case "new_task": {
@@ -448,6 +393,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:
@@ -457,185 +404,105 @@ export async function presentAssistantMessage(cline: Task) {
if (cline.didRejectTool) {
// Ignore any tool content after user has rejected tool once.
- // For native protocol, we must send a tool_result for every tool_use to avoid API errors
- const toolCallId = block.id
+ // For native tool calling, we must send a tool_result for every tool_use to avoid API errors
const errorMessage = !block.partial
? `Skipping tool ${toolDescription()} due to user rejecting a previous tool.`
: `Tool ${toolDescription()} was interrupted and not executed due to user rejecting a previous tool.`
- if (toolCallId) {
- // Native protocol: MUST send tool_result for every tool_use
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
- content: errorMessage,
- is_error: true,
- })
- } else {
- // XML protocol: send as text
- cline.userMessageContent.push({
- type: "text",
- text: errorMessage,
- })
- }
+ cline.pushToolResultToUserContent({
+ type: "tool_result",
+ tool_use_id: sanitizeToolUseId(toolCallId),
+ content: errorMessage,
+ is_error: true,
+ })
break
}
- if (cline.didAlreadyUseTool) {
- // Ignore any content after a tool has already been used.
- // For native protocol, we must send a tool_result for every tool_use to avoid API errors
- const toolCallId = block.id
- const errorMessage = `Tool [${block.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`
-
- if (toolCallId) {
- // Native protocol: MUST send tool_result for every tool_use
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
- content: errorMessage,
- is_error: true,
- })
- } else {
- // XML protocol: send as text
- cline.userMessageContent.push({
- type: "text",
- text: errorMessage,
- })
- }
-
- break
- }
-
- // Track if we've already pushed a tool result for this tool call (native protocol only)
+ // Track if we've already pushed a tool result for this tool call (native tool calling only)
let hasToolResult = false
- // Determine protocol by checking if this tool call has an ID.
- // Native protocol tool calls ALWAYS have an ID (set when parsed from tool_call chunks).
- // XML protocol tool calls NEVER have an ID (parsed from XML text).
- const toolCallId = (block as any).id
- const toolProtocol = toolCallId ? TOOL_PROTOCOL.NATIVE : TOOL_PROTOCOL.XML
+ // If this is a native tool call but the parser couldn't construct nativeArgs
+ // (e.g., malformed/unfinished JSON in a streaming tool call), we must NOT attempt to
+ // execute the tool. Instead, emit exactly one structured tool_result so the provider
+ // receives a matching tool_result for the tool_use_id.
+ //
+ // This avoids executing an invalid tool_use block and prevents duplicate/fragmented
+ // error reporting.
+ if (!block.partial) {
+ const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined
+ const isKnownTool = isValidToolName(String(block.name), stateExperiments)
+ if (isKnownTool && !block.nativeArgs && !customTool) {
+ const errorMessage =
+ `Invalid tool call for '${block.name}': missing nativeArgs. ` +
+ `This usually means the model streamed invalid or incomplete arguments and the call could not be finalized.`
- // Multiple native tool calls feature is on hold - always disabled
- // Previously resolved from experiments.isEnabled(..., EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS)
- const isMultipleNativeToolCallsEnabled = false
+ cline.consecutiveMistakeCount++
+ try {
+ cline.recordToolError(block.name as ToolName, errorMessage)
+ } catch {
+ // Best-effort only
+ }
+
+ // Push tool_result directly without setting didAlreadyUseTool so streaming can
+ // continue gracefully.
+ cline.pushToolResultToUserContent({
+ type: "tool_result",
+ tool_use_id: sanitizeToolUseId(toolCallId),
+ content: formatResponse.toolError(errorMessage),
+ is_error: true,
+ })
+
+ break
+ }
+ }
// Store approval feedback to merge into tool result (GitHub #10465)
let approvalFeedback: { text: string; images?: string[] } | undefined
const pushToolResult = (content: ToolResponse) => {
- if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
- // For native protocol, only allow ONE tool_result per tool call
- if (hasToolResult) {
- console.warn(
- `[presentAssistantMessage] Skipping duplicate tool_result for tool_use_id: ${toolCallId}`,
- )
- return
- }
+ // Native tool calling: only allow ONE tool_result per tool call
+ if (hasToolResult) {
+ console.warn(
+ `[presentAssistantMessage] Skipping duplicate tool_result for tool_use_id: ${toolCallId}`,
+ )
+ return
+ }
- // For native protocol, tool_result content must be a string
- // Images are added as separate blocks in the user message
- let resultContent: string
- let imageBlocks: Anthropic.ImageBlockParam[] = []
+ let resultContent: string
+ let imageBlocks: Anthropic.ImageBlockParam[] = []
- if (typeof content === "string") {
- resultContent = content || "(tool did not return anything)"
- } else {
- // Separate text and image blocks
- const textBlocks = content.filter((item) => item.type === "text")
- imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[]
-
- // Convert text blocks to string for tool_result
- resultContent =
- textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") ||
- "(tool did not return anything)"
- }
-
- // Merge approval feedback into tool result (GitHub #10465)
- if (approvalFeedback) {
- const feedbackText = formatResponse.toolApprovedWithFeedback(
- approvalFeedback.text,
- toolProtocol,
- )
- resultContent = `${feedbackText}\n\n${resultContent}`
-
- // Add feedback images to the image blocks
- if (approvalFeedback.images) {
- const feedbackImageBlocks = formatResponse.imageBlocks(approvalFeedback.images)
- imageBlocks = [...feedbackImageBlocks, ...imageBlocks]
- }
- }
-
- // Add tool_result with text content only
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
- content: resultContent,
- })
-
- // Add image blocks separately after tool_result
- if (imageBlocks.length > 0) {
- cline.userMessageContent.push(...imageBlocks)
- }
-
- hasToolResult = true
+ if (typeof content === "string") {
+ resultContent = content || "(tool did not return anything)"
} else {
- // For XML protocol, add as text blocks (legacy behavior)
- let resultContent: string
+ const textBlocks = content.filter((item) => item.type === "text")
+ imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[]
+ resultContent =
+ textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") ||
+ "(tool did not return anything)"
+ }
- if (typeof content === "string") {
- resultContent = content || "(tool did not return anything)"
- } else {
- const textBlocks = content.filter((item) => item.type === "text")
- resultContent =
- textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") ||
- "(tool did not return anything)"
- }
-
- // Merge approval feedback into tool result (GitHub #10465)
- if (approvalFeedback) {
- const feedbackText = formatResponse.toolApprovedWithFeedback(
- approvalFeedback.text,
- toolProtocol,
- )
- resultContent = `${feedbackText}\n\n${resultContent}`
- }
-
- cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` })
-
- if (typeof content === "string") {
- cline.userMessageContent.push({
- type: "text",
- text: resultContent,
- })
- } else {
- // Add text content with merged feedback
- cline.userMessageContent.push({
- type: "text",
- text: resultContent,
- })
- // Add any images from the tool result
- const imageBlocks = content.filter((item) => item.type === "image")
- if (imageBlocks.length > 0) {
- cline.userMessageContent.push(...imageBlocks)
- }
+ // Merge approval feedback into tool result (GitHub #10465)
+ if (approvalFeedback) {
+ const feedbackText = formatResponse.toolApprovedWithFeedback(approvalFeedback.text)
+ resultContent = `${feedbackText}\n\n${resultContent}`
+ if (approvalFeedback.images) {
+ const feedbackImageBlocks = formatResponse.imageBlocks(approvalFeedback.images)
+ imageBlocks = [...feedbackImageBlocks, ...imageBlocks]
}
}
- // For XML protocol: Only one tool per message is allowed
- // For native protocol with experimental flag enabled: Multiple tools can be executed in sequence
- // For native protocol with experimental flag disabled: Single tool per message (default safe behavior)
- if (toolProtocol === TOOL_PROTOCOL.XML) {
- // Once a tool result has been collected, ignore all other tool
- // uses since we should only ever present one tool result per
- // message (XML protocol only).
- cline.didAlreadyUseTool = true
- } else if (toolProtocol === TOOL_PROTOCOL.NATIVE && !isMultipleNativeToolCallsEnabled) {
- // For native protocol with experimental flag disabled, enforce single tool per message
- cline.didAlreadyUseTool = true
+ cline.pushToolResultToUserContent({
+ type: "tool_result",
+ tool_use_id: sanitizeToolUseId(toolCallId),
+ content: resultContent,
+ })
+
+ if (imageBlocks.length > 0) {
+ cline.userMessageContent.push(...imageBlocks)
}
- // If toolProtocol is NATIVE and isMultipleNativeToolCallsEnabled is true,
- // allow multiple tool calls in sequence (don't set didAlreadyUseTool)
+
+ hasToolResult = true
}
const askApproval = async (
@@ -656,14 +523,9 @@ export async function presentAssistantMessage(cline: Task) {
// Handle both messageResponse and noButtonClicked with text.
if (text) {
await cline.say("user_feedback", text, images)
- pushToolResult(
- formatResponse.toolResult(
- formatResponse.toolDeniedWithFeedback(text, toolProtocol),
- images,
- ),
- )
+ pushToolResult(formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images))
} else {
- pushToolResult(formatResponse.toolDenied(toolProtocol))
+ pushToolResult(formatResponse.toolDenied())
}
cline.didRejectTool = true
return false
@@ -702,34 +564,7 @@ export async function presentAssistantMessage(cline: Task) {
`Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`,
)
- pushToolResult(formatResponse.toolError(errorString, toolProtocol))
- }
-
- // If block is partial, remove partial closing tag so its not
- // presented to user.
- const removeClosingTag = (tag: ToolParamName, text?: string): string => {
- if (!block.partial) {
- return text || ""
- }
-
- if (!text) {
- return ""
- }
-
- // This regex dynamically constructs a pattern to match the
- // closing tag:
- // - Optionally matches whitespace before the tag.
- // - Matches '<' or '' optionally followed by any subset of
- // characters from the tag name.
- const tagRegex = new RegExp(
- `\\s?<\/?${tag
- .split("")
- .map((char) => `(?:${char})?`)
- .join("")}$`,
- "g",
- )
-
- return text.replace(tagRegex, "")
+ pushToolResult(formatResponse.toolError(errorString))
}
// Keep browser open during an active session so other tools can run.
@@ -765,7 +600,16 @@ export async function presentAssistantMessage(cline: Task) {
const isCustomTool = stateExperiments?.customTools && customToolRegistry.has(block.name)
const recordName = isCustomTool ? "custom_tool" : block.name
cline.recordToolUsage(recordName)
- TelemetryService.instance.captureToolUsage(cline.taskId, recordName, toolProtocol)
+ 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.
@@ -785,7 +629,7 @@ export async function presentAssistantMessage(cline: Task) {
block.name as ToolName,
mode ?? defaultModeSlug,
customModes ?? [],
- { apply_diff: cline.diffEnabled },
+ {},
block.params,
stateExperiments,
includedTools,
@@ -793,24 +637,18 @@ export async function presentAssistantMessage(cline: Task) {
} catch (error) {
cline.consecutiveMistakeCount++
// For validation errors (unknown tool, tool not allowed for mode), we need to:
- // 1. Send a tool_result with the error (required for native protocol)
+ // 1. Send a tool_result with the error (required for native tool calling)
// 2. NOT set didAlreadyUseTool = true (the tool was never executed, just failed validation)
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
// which would cause the extension to appear to hang
- const errorContent = formatResponse.toolError(error.message, toolProtocol)
-
- if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
- // For native protocol, push tool_result directly without setting didAlreadyUseTool
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
- content: typeof errorContent === "string" ? errorContent : "(validation error)",
- is_error: true,
- })
- } else {
- // For XML protocol, use the standard pushToolResult
- pushToolResult(errorContent)
- }
+ const errorContent = formatResponse.toolError(error.message)
+ // Push tool_result directly without setting didAlreadyUseTool
+ cline.pushToolResultToUserContent({
+ type: "tool_result",
+ tool_use_id: sanitizeToolUseId(toolCallId),
+ content: typeof errorContent === "string" ? errorContent : "(validation error)",
+ is_error: true,
+ })
break
}
@@ -862,7 +700,6 @@ export async function presentAssistantMessage(cline: Task) {
pushToolResult(
formatResponse.toolError(
`Tool call repetition limit reached for ${block.name}. Please try a different approach.`,
- toolProtocol,
),
)
break
@@ -876,8 +713,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "update_todo_list":
@@ -885,59 +720,22 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
- case "apply_diff": {
+ case "apply_diff":
await checkpointSaveAndMark(cline)
-
- // Check if this tool call came from native protocol by checking for ID
- // Native calls always have IDs, XML calls never do
- if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
- await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- }
-
- // Get the provider and state to check experiment settings
- const provider = cline.providerRef.deref()
- let isMultiFileApplyDiffEnabled = false
-
- if (provider) {
- const state = await provider.getState()
- isMultiFileApplyDiffEnabled = experiments.isEnabled(
- state.experiments ?? {},
- EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
- )
- }
-
- if (isMultiFileApplyDiffEnabled) {
- await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
- } else {
- await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- }
+ await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
+ askApproval,
+ handleError,
+ pushToolResult,
+ })
break
- }
case "search_and_replace":
await checkpointSaveAndMark(cline)
await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "search_replace":
@@ -946,8 +744,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "edit_file":
@@ -956,8 +752,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "apply_patch":
@@ -966,8 +760,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "read_file":
@@ -976,17 +768,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "fetch_instructions":
- await fetchInstructionsTool.handle(cline, block as ToolUse<"fetch_instructions">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "list_files":
@@ -994,8 +775,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "codebase_search":
@@ -1003,8 +782,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "search_files":
@@ -1012,8 +789,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "browser_action":
@@ -1023,7 +798,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
)
break
case "execute_command":
@@ -1031,8 +805,13 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
+ })
+ break
+ case "read_command_output":
+ await readCommandOutputTool.handle(cline, block as ToolUse<"read_command_output">, {
+ askApproval,
+ handleError,
+ pushToolResult,
})
break
case "use_mcp_tool":
@@ -1040,8 +819,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "access_mcp_resource":
@@ -1049,8 +826,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "ask_followup_question":
@@ -1058,8 +833,6 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "switch_mode":
@@ -1067,17 +840,14 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
case "new_task":
+ await checkpointSaveAndMark(cline)
await newTaskTool.handle(cline, block as ToolUse<"new_task">, {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
toolCallId: block.id,
})
break
@@ -1086,10 +856,8 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
askFinishSubTaskApproval,
toolDescription,
- toolProtocol,
}
await attemptCompletionTool.handle(
cline,
@@ -1103,8 +871,13 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
+ })
+ break
+ case "skill":
+ await skillTool.handle(cline, block as ToolUse<"skill">, {
+ askApproval,
+ handleError,
+ pushToolResult,
})
break
case "generate_image":
@@ -1113,13 +886,11 @@ export async function presentAssistantMessage(cline: Task) {
askApproval,
handleError,
pushToolResult,
- removeClosingTag,
- toolProtocol,
})
break
default: {
// Handle unknown/invalid tool names OR custom tools
- // This is critical for native protocol where every tool_use MUST have a tool_result
+ // This is critical for native tool calling where every tool_use MUST have a tool_result
// CRITICAL: Don't process partial blocks for unknown tools - just let them stream in.
// If we try to show errors for partial blocks, we'd show the error on every streaming chunk,
@@ -1142,7 +913,7 @@ export async function presentAssistantMessage(cline: Task) {
console.error(message)
cline.consecutiveMistakeCount++
await cline.say("error", message)
- pushToolResult(formatResponse.toolError(message, toolProtocol))
+ pushToolResult(formatResponse.toolError(message))
break
}
}
@@ -1173,18 +944,14 @@ export async function presentAssistantMessage(cline: Task) {
cline.consecutiveMistakeCount++
cline.recordToolError(block.name as ToolName, errorMessage)
await cline.say("error", t("tools:unknownToolError", { toolName: block.name }))
- // Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool
+ // Push tool_result directly WITHOUT setting didAlreadyUseTool
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
- if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
- content: formatResponse.toolError(errorMessage, toolProtocol),
- is_error: true,
- })
- } else {
- pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
- }
+ cline.pushToolResultToUserContent({
+ type: "tool_result",
+ tool_use_id: sanitizeToolUseId(toolCallId),
+ content: formatResponse.toolError(errorMessage),
+ is_error: true,
+ })
break
}
}
@@ -1264,3 +1031,47 @@ async function checkpointSaveAndMark(task: Task) {
console.error(`[Task#presentAssistantMessage] Error saving checkpoint: ${error.message}`, error)
}
}
+
+function containsXmlToolMarkup(text: string): boolean {
+ // Keep this intentionally narrow: only reject XML-style tool tags matching our tool names.
+ // Avoid regex so we don't keep legacy XML parsing artifacts around.
+ // Note: This is a best-effort safeguard; tool_use blocks without an id are rejected elsewhere.
+
+ // First, strip out content inside markdown code fences to avoid false positives
+ // when users paste documentation or examples containing tool tag references.
+ // This handles both fenced code blocks (```) and inline code (`).
+ const textWithoutCodeBlocks = text
+ .replace(/```[\s\S]*?```/g, "") // Remove fenced code blocks
+ .replace(/`[^`]+`/g, "") // Remove inline code
+
+ const lower = textWithoutCodeBlocks.toLowerCase()
+ if (!lower.includes("<") || !lower.includes(">")) {
+ return false
+ }
+
+ const toolNames = [
+ "access_mcp_resource",
+ "apply_diff",
+ "apply_patch",
+ "ask_followup_question",
+ "attempt_completion",
+ "browser_action",
+ "codebase_search",
+ "edit_file",
+ "execute_command",
+ "generate_image",
+ "list_files",
+ "new_task",
+ "read_command_output",
+ "read_file",
+ "search_and_replace",
+ "search_files",
+ "search_replace",
+ "switch_mode",
+ "update_todo_list",
+ "use_mcp_tool",
+ "write_to_file",
+ ] as const
+
+ return toolNames.some((name) => lower.includes(`<${name}`) || lower.includes(`${name}`))
+}
diff --git a/src/core/assistant-message/types.ts b/src/core/assistant-message/types.ts
new file mode 100644
index 0000000000..7cd890cfdd
--- /dev/null
+++ b/src/core/assistant-message/types.ts
@@ -0,0 +1,3 @@
+import type { TextContent, ToolUse, McpToolUse } from "../../shared/tools"
+
+export type AssistantMessageContent = TextContent | ToolUse | McpToolUse
diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts
index f295140501..f9de2ccfe3 100644
--- a/src/core/auto-approval/index.ts
+++ b/src/core/auto-approval/index.ts
@@ -151,14 +151,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/condense/__tests__/condense.spec.ts b/src/core/condense/__tests__/condense.spec.ts
index bea7d50ac1..c209fa9724 100644
--- a/src/core/condense/__tests__/condense.spec.ts
+++ b/src/core/condense/__tests__/condense.spec.ts
@@ -10,7 +10,7 @@ import {
summarizeConversation,
getMessagesSinceLastSummary,
getEffectiveApiHistory,
- N_MESSAGES_TO_KEEP,
+ extractCommandBlocks,
} from "../index"
// Create a mock ApiHandler for testing
@@ -63,8 +63,67 @@ describe("Condense", () => {
}
})
+ describe("extractCommandBlocks", () => {
+ it("should extract command blocks from string content", () => {
+ const message: ApiMessage = {
+ role: "user",
+ content: 'Some text /prr #123 more text',
+ }
+
+ const result = extractCommandBlocks(message)
+ expect(result).toBe('/prr #123 ')
+ })
+
+ it("should extract multiple command blocks", () => {
+ const message: ApiMessage = {
+ role: "user",
+ content: '/prr #123 text /mode code ',
+ }
+
+ const result = extractCommandBlocks(message)
+ expect(result).toBe('/prr #123 \n/mode code ')
+ })
+
+ it("should extract command blocks from array content", () => {
+ const message: ApiMessage = {
+ role: "user",
+ content: [
+ { type: "text", text: "Some user text" },
+ { type: "text", text: 'Help content ' },
+ ],
+ }
+
+ const result = extractCommandBlocks(message)
+ expect(result).toBe('Help content ')
+ })
+
+ it("should return empty string when no command blocks found", () => {
+ const message: ApiMessage = {
+ role: "user",
+ content: "Just regular text without commands",
+ }
+
+ const result = extractCommandBlocks(message)
+ expect(result).toBe("")
+ })
+
+ it("should handle multiline command blocks", () => {
+ const message: ApiMessage = {
+ role: "user",
+ content: `
+Line 1
+Line 2
+ `,
+ }
+
+ const result = extractCommandBlocks(message)
+ expect(result).toContain("Line 1")
+ expect(result).toContain("Line 2")
+ })
+ })
+
describe("summarizeConversation", () => {
- it("should preserve the first message when summarizing", async () => {
+ it("should create a summary message with role user (fresh start model)", async () => {
const messages: ApiMessage[] = [
{ role: "user", content: "First message with /prr command content" },
{ role: "assistant", content: "Second message" },
@@ -77,59 +136,95 @@ describe("Condense", () => {
{ role: "user", content: "Ninth message" },
]
- const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false)
+ const result = await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: false,
+ })
- // Verify the first message is preserved
- expect(result.messages[0]).toEqual(messages[0])
- expect(result.messages[0].content).toBe("First message with /prr command content")
-
- // Verify we have a summary message
+ // Verify we have a summary message with role "user" (fresh start model)
const summaryMessage = result.messages.find((msg) => msg.isSummary)
expect(summaryMessage).toBeTruthy()
- // Summary content is now always an array with a synthetic reasoning block + text block
- // for DeepSeek-reasoner compatibility
- expect(Array.isArray(summaryMessage?.content)).toBe(true)
- const contentArray = summaryMessage?.content as Anthropic.Messages.ContentBlockParam[]
- expect(contentArray).toHaveLength(2)
- expect(contentArray[0]).toEqual({
- type: "reasoning",
- text: "Condensing conversation context. The summary below captures the key information from the prior conversation.",
- })
- expect(contentArray[1]).toEqual({
- type: "text",
- text: "Mock summary of the conversation",
- })
+ expect(summaryMessage!.role).toBe("user")
+ expect(Array.isArray(summaryMessage!.content)).toBe(true)
+ const contentArray = summaryMessage!.content as any[]
+ expect(contentArray.some((b) => b.type === "text")).toBe(true)
+ // Should NOT have reasoning blocks (no longer needed for user messages)
+ expect(contentArray.some((b) => b.type === "reasoning")).toBe(false)
- // With non-destructive condensing, all messages are retained (tagged but not deleted)
- // Use getEffectiveApiHistory to verify the effective view matches the old behavior
- expect(result.messages.length).toBe(messages.length + 1) // All original messages + summary
+ // Fresh start model: effective history should only contain the summary
const effectiveHistory = getEffectiveApiHistory(result.messages)
- expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // first + summary + last N
-
- // Verify the last N messages are preserved (same messages by reference)
- const lastMessages = result.messages.slice(-N_MESSAGES_TO_KEEP)
- expect(lastMessages).toEqual(messages.slice(-N_MESSAGES_TO_KEEP))
+ expect(effectiveHistory.length).toBe(1)
+ expect(effectiveHistory[0].isSummary).toBe(true)
+ expect(effectiveHistory[0].role).toBe("user")
})
- it("should preserve slash command content in the first message", async () => {
- const slashCommandContent = "/prr #123 - Fix authentication bug"
+ it("should tag ALL messages with condenseParent", async () => {
const messages: ApiMessage[] = [
- { role: "user", content: slashCommandContent },
- { role: "assistant", content: "I'll help you fix that authentication bug" },
- { role: "user", content: "The issue is with JWT tokens" },
- { role: "assistant", content: "Let me examine the JWT implementation" },
- { role: "user", content: "It's failing on refresh" },
- { role: "assistant", content: "I found the issue" },
- { role: "user", content: "Great, can you fix it?" },
- { role: "assistant", content: "Here's the fix" },
- { role: "user", content: "Thanks!" },
+ { role: "user", content: "First message with /prr command content" },
+ { role: "assistant", content: "Second message" },
+ { role: "user", content: "Third message" },
+ { role: "assistant", content: "Fourth message" },
+ { role: "user", content: "Fifth message" },
]
- const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false)
+ const result = await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: false,
+ })
- // The first message with slash command should be intact
- expect(result.messages[0].content).toBe(slashCommandContent)
- expect(result.messages[0]).toEqual(messages[0])
+ // All original messages should be tagged with condenseParent
+ const taggedMessages = result.messages.filter((msg) => !msg.isSummary)
+ expect(taggedMessages.length).toBe(messages.length)
+ for (const msg of taggedMessages) {
+ expect(msg.condenseParent).toBeDefined()
+ }
+ })
+
+ it("should preserve blocks in the summary", async () => {
+ const messages: ApiMessage[] = [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "Some user text" },
+ { type: "text", text: 'Help content ' },
+ ],
+ },
+ { role: "assistant", content: "Second message" },
+ { role: "user", content: "Third message" },
+ { role: "assistant", content: "Fourth message" },
+ { role: "user", content: "Fifth message" },
+ { role: "assistant", content: "Sixth message" },
+ { role: "user", content: "Seventh message" },
+ { role: "assistant", content: "Eighth message" },
+ { role: "user", content: "Ninth message" },
+ ]
+
+ const result = await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: false,
+ })
+
+ const summaryMessage = result.messages.find((msg) => msg.isSummary)
+ expect(summaryMessage).toBeTruthy()
+
+ const contentArray = summaryMessage!.content as any[]
+ // Summary content is split into separate text blocks:
+ // - First block: "## Conversation Summary\n..."
+ // - Second block: "..." with command blocks
+ expect(contentArray).toHaveLength(2)
+ expect(contentArray[0].text).toContain("## Conversation Summary")
+ expect(contentArray[1].text).toContain('')
+ expect(contentArray[1].text).toContain("")
+ expect(contentArray[1].text).toContain("Active Workflows")
})
it("should handle complex first message content", async () => {
@@ -150,43 +245,53 @@ describe("Condense", () => {
{ role: "user", content: "Perfect!" },
]
- const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false)
+ const result = await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: false,
+ })
- // The first message with complex content should be preserved
- expect(result.messages[0].content).toEqual(complexContent)
- expect(result.messages[0]).toEqual(messages[0])
+ // Effective history should contain only the summary (fresh start)
+ const effectiveHistory = getEffectiveApiHistory(result.messages)
+ expect(effectiveHistory).toHaveLength(1)
+ expect(effectiveHistory[0].isSummary).toBe(true)
+ expect(effectiveHistory[0].role).toBe("user")
})
it("should return error when not enough messages to summarize", async () => {
- const messages: ApiMessage[] = [
- { role: "user", content: "First message with /command" },
- { role: "assistant", content: "Second message" },
- { role: "user", content: "Third message" },
- { role: "assistant", content: "Fourth message" },
- ]
+ const messages: ApiMessage[] = [{ role: "user", content: "Only one message" }]
- const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false)
+ const result = await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: false,
+ })
- // Should return an error since we have only 4 messages (first + 3 to keep)
+ // Should return an error since we have only 1 message
expect(result.error).toBeDefined()
expect(result.messages).toEqual(messages) // Original messages unchanged
expect(result.summary).toBe("")
})
- it("should not summarize messages that already contain a recent summary", async () => {
+ it("should not summarize messages that already contain a recent summary with no new messages", async () => {
const messages: ApiMessage[] = [
{ role: "user", content: "First message with /command" },
- { role: "assistant", content: "Old message" },
- { role: "user", content: "Message before summary" },
- { role: "assistant", content: "Response" },
- { role: "user", content: "Another message" },
- { role: "assistant", content: "Previous summary", isSummary: true }, // Summary in last N messages
- { role: "user", content: "Final message" },
+ { role: "user", content: "Previous summary", isSummary: true },
]
- const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, 5000, false)
+ const result = await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: false,
+ })
- // Should return an error due to recent summary in last N messages
+ // Should return an error due to recent summary with no substantial messages after
expect(result.error).toBeDefined()
expect(result.messages).toEqual(messages)
expect(result.summary).toBe("")
@@ -217,7 +322,13 @@ describe("Condense", () => {
{ role: "user", content: "Seventh" },
]
- const result = await summarizeConversation(messages, emptyHandler, "System prompt", taskId, 5000, false)
+ const result = await summarizeConversation({
+ messages,
+ apiHandler: emptyHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: false,
+ })
expect(result.error).toBeDefined()
expect(result.messages).toEqual(messages)
@@ -225,6 +336,81 @@ describe("Condense", () => {
})
})
+ describe("getEffectiveApiHistory", () => {
+ it("should return only summary when summary exists (fresh start)", () => {
+ const condenseId = "test-condense-id"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", condenseParent: condenseId },
+ { role: "assistant", content: "Second", condenseParent: condenseId },
+ { role: "user", content: "Third", condenseParent: condenseId },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary content" }],
+ isSummary: true,
+ condenseId,
+ },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ expect(result).toHaveLength(1)
+ expect(result[0].isSummary).toBe(true)
+ })
+
+ it("should include messages after summary in fresh start model", () => {
+ const condenseId = "test-condense-id"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", condenseParent: condenseId },
+ { role: "assistant", content: "Second", condenseParent: condenseId },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary content" }],
+ isSummary: true,
+ condenseId,
+ },
+ { role: "assistant", content: "New response after summary" },
+ { role: "user", content: "New user message" },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ expect(result).toHaveLength(3)
+ expect(result[0].isSummary).toBe(true)
+ expect(result[1].content).toBe("New response after summary")
+ expect(result[2].content).toBe("New user message")
+ })
+
+ it("should return all messages when no summary exists", () => {
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First" },
+ { role: "assistant", content: "Second" },
+ { role: "user", content: "Third" },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ expect(result).toEqual(messages)
+ })
+
+ it("should restore messages when summary is deleted (rewind)", () => {
+ // After rewind, summary is deleted but condenseParent tags remain as orphans
+ // The cleanupAfterTruncation function would normally clear these,
+ // but even without cleanup, getEffectiveApiHistory should handle orphaned tags
+ const orphanedCondenseId = "deleted-summary-id"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", condenseParent: orphanedCondenseId },
+ { role: "assistant", content: "Second", condenseParent: orphanedCondenseId },
+ { role: "user", content: "Third", condenseParent: orphanedCondenseId },
+ // Summary was deleted - no isSummary message exists
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ // With no summary, all messages should be included (orphaned condenseParent is ignored)
+ expect(result).toHaveLength(3)
+ })
+ })
+
describe("getMessagesSinceLastSummary", () => {
it("should return all messages when no summary exists", () => {
const messages: ApiMessage[] = [
@@ -241,39 +427,33 @@ describe("Condense", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "First message" },
{ role: "assistant", content: "Second message" },
- { role: "assistant", content: "Summary content", isSummary: true },
- { role: "user", content: "Message after summary" },
- { role: "assistant", content: "Final message" },
+ { role: "user", content: "Summary content", isSummary: true },
+ { role: "assistant", content: "Message after summary" },
+ { role: "user", content: "Final message" },
]
const result = getMessagesSinceLastSummary(messages)
- // Should include the original first user message for context preservation, the summary, and messages after
- expect(result[0].role).toBe("user")
- expect(result[0].content).toBe("First message") // Preserves original first message
- expect(result[1]).toEqual(messages[2]) // The summary
- expect(result[2]).toEqual(messages[3])
- expect(result[3]).toEqual(messages[4])
+ expect(result[0]).toEqual(messages[2]) // The summary
+ expect(result[1]).toEqual(messages[3])
+ expect(result[2]).toEqual(messages[4])
})
it("should handle multiple summaries and return from the last one", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "First message" },
- { role: "assistant", content: "First summary", isSummary: true },
- { role: "user", content: "Middle message" },
- { role: "assistant", content: "Second summary", isSummary: true },
- { role: "user", content: "Recent message" },
- { role: "assistant", content: "Final message" },
+ { role: "user", content: "First summary", isSummary: true },
+ { role: "assistant", content: "Middle message" },
+ { role: "user", content: "Second summary", isSummary: true },
+ { role: "assistant", content: "Recent message" },
+ { role: "user", content: "Final message" },
]
const result = getMessagesSinceLastSummary(messages)
- // Should only include from the last summary with original first message preserved
- expect(result[0].role).toBe("user")
- expect(result[0].content).toBe("First message") // Preserves original first message
- expect(result[1]).toEqual(messages[3]) // Second summary
- expect(result[2]).toEqual(messages[4])
- expect(result[3]).toEqual(messages[5])
+ expect(result[0]).toEqual(messages[3]) // Second summary
+ expect(result[1]).toEqual(messages[4])
+ expect(result[2]).toEqual(messages[5])
})
})
})
diff --git a/src/core/condense/__tests__/foldedFileContext.spec.ts b/src/core/condense/__tests__/foldedFileContext.spec.ts
new file mode 100644
index 0000000000..3bd9b390f5
--- /dev/null
+++ b/src/core/condense/__tests__/foldedFileContext.spec.ts
@@ -0,0 +1,391 @@
+// npx vitest src/core/condense/__tests__/foldedFileContext.spec.ts
+
+import * as path from "path"
+import { Anthropic } from "@anthropic-ai/sdk"
+import type { ModelInfo } from "@roo-code/types"
+import { TelemetryService } from "@roo-code/telemetry"
+import { BaseProvider } from "../../../api/providers/base-provider"
+
+// Mock the tree-sitter module
+vi.mock("../../../services/tree-sitter", () => ({
+ parseSourceCodeDefinitionsForFile: vi.fn(),
+}))
+
+// Mock generateFoldedFileContext for summarizeConversation tests
+vi.mock("../foldedFileContext", async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ generateFoldedFileContext: vi.fn().mockImplementation(actual.generateFoldedFileContext),
+ }
+})
+
+import { generateFoldedFileContext } from "../foldedFileContext"
+import { parseSourceCodeDefinitionsForFile } from "../../../services/tree-sitter"
+
+const mockedGenerateFoldedFileContext = vi.mocked(generateFoldedFileContext)
+
+const mockedParseSourceCodeDefinitions = vi.mocked(parseSourceCodeDefinitionsForFile)
+
+describe("foldedFileContext", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe("generateFoldedFileContext", () => {
+ it("should return empty content for empty file list", async () => {
+ const result = await generateFoldedFileContext([], { cwd: "/test" })
+
+ expect(result.content).toBe("")
+ expect(result.sections).toEqual([])
+ expect(result.filesProcessed).toBe(0)
+ expect(result.filesSkipped).toBe(0)
+ expect(result.characterCount).toBe(0)
+ })
+
+ it("should generate folded context for a TypeScript file with its own system-reminder block", async () => {
+ const mockDefinitions = `1--5 | export interface User
+7--12 | export function createUser(name: string): User
+14--28 | export class UserService`
+
+ mockedParseSourceCodeDefinitions.mockResolvedValue(mockDefinitions)
+
+ const result = await generateFoldedFileContext(["/test/user.ts"], { cwd: "/test" })
+
+ // Each file should be wrapped in its own block
+ expect(result.content).toContain("")
+ expect(result.content).toContain(" ")
+ expect(result.content).toContain("## File Context: /test/user.ts")
+ expect(result.content).toContain("interface User")
+ expect(result.content).toContain("function createUser")
+ expect(result.content).toContain("class UserService")
+ expect(result.filesProcessed).toBe(1)
+ expect(result.filesSkipped).toBe(0)
+ })
+
+ it("should generate folded context for a JavaScript file with its own system-reminder block", async () => {
+ const mockDefinitions = `1--3 | function greet(name)
+5--15 | class Calculator`
+
+ mockedParseSourceCodeDefinitions.mockResolvedValue(mockDefinitions)
+
+ const result = await generateFoldedFileContext(["/test/utils.js"], { cwd: "/test" })
+
+ expect(result.content).toContain("")
+ expect(result.content).toContain("## File Context: /test/utils.js")
+ expect(result.content).toContain("function greet")
+ expect(result.content).toContain("class Calculator")
+ expect(result.filesProcessed).toBe(1)
+ })
+
+ it("should skip files when parseSourceCodeDefinitions returns undefined", async () => {
+ // First file succeeds, second returns undefined
+ mockedParseSourceCodeDefinitions
+ .mockResolvedValueOnce("1--3 | export const x = 1")
+ .mockResolvedValueOnce(undefined)
+
+ const result = await generateFoldedFileContext(["/test/existing.ts", "/test/unsupported.txt"], {
+ cwd: "/test",
+ })
+
+ expect(result.filesProcessed).toBe(1)
+ expect(result.filesSkipped).toBe(1)
+ })
+
+ it("should skip files when parseSourceCodeDefinitions throws an error", async () => {
+ mockedParseSourceCodeDefinitions
+ .mockResolvedValueOnce("1--3 | export const x = 1")
+ .mockRejectedValueOnce(new Error("File not found"))
+
+ const result = await generateFoldedFileContext(["/test/existing.ts", "/test/non-existent.ts"], {
+ cwd: "/test",
+ })
+
+ expect(result.filesProcessed).toBe(1)
+ expect(result.filesSkipped).toBe(1)
+ })
+
+ it("should skip files when parseSourceCodeDefinitions returns error strings", async () => {
+ // Tree-sitter can return error strings for missing or denied files
+ // These should be treated as skipped, not embedded in the output
+ mockedParseSourceCodeDefinitions
+ .mockResolvedValueOnce("1--3 | export const x = 1")
+ .mockResolvedValueOnce("This file does not exist or you do not have permission to access it.")
+ .mockResolvedValueOnce("Unsupported file type: /test/file.xyz")
+
+ const result = await generateFoldedFileContext(["/test/valid.ts", "/test/missing.ts", "/test/file.xyz"], {
+ cwd: "/test",
+ })
+
+ // Only the first file should be processed, the other two return error strings
+ expect(result.filesProcessed).toBe(1)
+ expect(result.filesSkipped).toBe(2)
+
+ // The content should NOT contain the error messages
+ expect(result.content).not.toContain("does not exist")
+ expect(result.content).not.toContain("do not have permission")
+ expect(result.content).not.toContain("Unsupported file type")
+
+ // But it should contain the valid file's content
+ expect(result.content).toContain("## File Context: /test/valid.ts")
+ expect(result.content).toContain("export const x = 1")
+ })
+
+ it("should respect character budget limit", async () => {
+ // Create multiple files that would exceed a small budget
+ const longDefinitions = `1--3 | export function longFunctionName1()
+5--7 | export function longFunctionName2()
+9--11 | export function longFunctionName3()`
+
+ mockedParseSourceCodeDefinitions.mockResolvedValue(longDefinitions)
+
+ const result = await generateFoldedFileContext(["/test/file1.ts", "/test/file2.ts", "/test/file3.ts"], {
+ cwd: "/test",
+ maxCharacters: 200, // Small budget
+ })
+
+ expect(result.characterCount).toBeLessThanOrEqual(200)
+ // Some files should be skipped due to budget limit
+ expect(result.filesSkipped).toBeGreaterThan(0)
+ })
+
+ it("should handle Python files with its own system-reminder block", async () => {
+ const mockDefinitions = `1--2 | def greet(name)
+4--12 | class Person`
+
+ mockedParseSourceCodeDefinitions.mockResolvedValue(mockDefinitions)
+
+ const result = await generateFoldedFileContext(["/test/person.py"], { cwd: "/test" })
+
+ expect(result.content).toContain("")
+ expect(result.content).toContain("## File Context: /test/person.py")
+ expect(result.content).toContain("def greet")
+ expect(result.content).toContain("class Person")
+ expect(result.filesProcessed).toBe(1)
+ })
+
+ it("should include file path in the File Context header", async () => {
+ mockedParseSourceCodeDefinitions.mockResolvedValue("1--3 | export function helper()")
+
+ const result = await generateFoldedFileContext(["/test/src/utils/helpers.ts"], { cwd: "/test" })
+
+ // The path should appear in the File Context header
+ expect(result.content).toContain("## File Context: /test/src/utils/helpers.ts")
+ })
+
+ it("should generate separate system-reminder blocks for multiple files", async () => {
+ mockedParseSourceCodeDefinitions
+ .mockResolvedValueOnce("1--3 | export async function fetchData(url: string): Promise")
+ .mockResolvedValueOnce("1--4 | export interface DataModel")
+
+ const result = await generateFoldedFileContext(["/test/api.ts", "/test/models.ts"], { cwd: "/test" })
+
+ // Each file should have its own block
+ const systemReminderMatches = result.content.match(//g)
+ expect(systemReminderMatches).toHaveLength(2)
+
+ // sections array should have separate entries for each file
+ expect(result.sections).toHaveLength(2)
+ expect(result.sections[0]).toContain("## File Context: /test/api.ts")
+ expect(result.sections[1]).toContain("## File Context: /test/models.ts")
+
+ expect(result.content).toContain("## File Context: /test/api.ts")
+ expect(result.content).toContain("## File Context: /test/models.ts")
+ expect(result.content).toContain("fetchData")
+ expect(result.content).toContain("interface DataModel")
+ expect(result.filesProcessed).toBe(2)
+ })
+
+ it("should truncate content when approaching character limit", async () => {
+ // Create a definition that would fit but is close to the limit
+ const longDefinitions = "1--3 | " + "x".repeat(300)
+
+ mockedParseSourceCodeDefinitions.mockResolvedValue(longDefinitions)
+
+ const result = await generateFoldedFileContext(["/test/file1.ts", "/test/file2.ts"], {
+ cwd: "/test",
+ maxCharacters: 350, // First file will fit, second will be truncated
+ })
+
+ // Content should include truncation marker if truncation happened
+ expect(result.filesProcessed + result.filesSkipped).toBe(2)
+ })
+ })
+
+ describe("summarizeConversation with foldedFileContext", () => {
+ beforeEach(() => {
+ if (!TelemetryService.hasInstance()) {
+ TelemetryService.createInstance([])
+ }
+ })
+
+ // Mock API handler for testing
+ class MockApiHandler extends BaseProvider {
+ createMessage(): any {
+ const mockStream = {
+ async *[Symbol.asyncIterator]() {
+ yield { type: "text", text: "Mock summary of the conversation" }
+ yield { type: "usage", inputTokens: 100, outputTokens: 50, totalCost: 0.01 }
+ },
+ }
+ return mockStream
+ }
+
+ getModel(): { id: string; info: ModelInfo } {
+ return {
+ id: "test-model",
+ info: {
+ contextWindow: 100000,
+ maxTokens: 50000,
+ supportsPromptCache: true,
+ supportsImages: false,
+ inputPrice: 0,
+ outputPrice: 0,
+ description: "Test model",
+ },
+ }
+ }
+
+ override async countTokens(content: Array): Promise {
+ let tokens = 0
+ for (const block of content) {
+ if (block.type === "text") {
+ tokens += Math.ceil(block.text.length / 4)
+ }
+ }
+ return tokens
+ }
+ }
+
+ it("should include folded file context with each file as a separate content block", async () => {
+ const { summarizeConversation } = await import("../index")
+
+ const mockApiHandler = new MockApiHandler()
+ const taskId = "test-task-id"
+
+ const messages: any[] = [
+ { role: "user", content: "First message" },
+ { role: "assistant", content: "Second message" },
+ { role: "user", content: "Third message" },
+ { role: "assistant", content: "Fourth message" },
+ { role: "user", content: "Fifth message" },
+ { role: "assistant", content: "Sixth message" },
+ { role: "user", content: "Seventh message" },
+ ]
+
+ // Mock generateFoldedFileContext to return the expected folded sections
+ const mockFoldedSections = [
+ `
+## File Context: src/user.ts
+1--5 | export interface User
+7--12 | export function createUser(name: string): User
+14--28 | export class UserService
+ `,
+ `
+## File Context: src/api.ts
+1--3 | export async function fetchData(url: string): Promise
+ `,
+ ]
+
+ mockedGenerateFoldedFileContext.mockResolvedValue({
+ content: mockFoldedSections.join("\n"),
+ sections: mockFoldedSections,
+ filesProcessed: 2,
+ filesSkipped: 0,
+ characterCount: mockFoldedSections.join("\n").length,
+ })
+
+ const filesReadByRoo = ["src/user.ts", "src/api.ts"]
+ const cwd = "/test/project"
+
+ const result = await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: false,
+ filesReadByRoo,
+ cwd,
+ })
+
+ // Verify generateFoldedFileContext was called with the right arguments
+ expect(mockedGenerateFoldedFileContext).toHaveBeenCalledWith(filesReadByRoo, {
+ cwd,
+ rooIgnoreController: undefined,
+ })
+
+ // Verify the summary was created
+ expect(result.summary).toBeDefined()
+ expect(result.messages.length).toBeGreaterThan(0)
+
+ // Find the summary message
+ const summaryMessage = result.messages.find((msg: any) => msg.isSummary)
+ expect(summaryMessage).toBeDefined()
+
+ // Each file should have its own content block
+ const contentArray = summaryMessage!.content as any[]
+
+ // Find the content blocks containing file contexts
+ const userFileBlock = contentArray.find(
+ (block: any) => block.type === "text" && block.text?.includes("## File Context: src/user.ts"),
+ )
+ const apiFileBlock = contentArray.find(
+ (block: any) => block.type === "text" && block.text?.includes("## File Context: src/api.ts"),
+ )
+
+ expect(userFileBlock).toBeDefined()
+ expect(apiFileBlock).toBeDefined()
+
+ // Each file block should have its own tags
+ expect(userFileBlock.text).toContain("")
+ expect(userFileBlock.text).toContain("export interface User")
+
+ expect(apiFileBlock.text).toContain("")
+ expect(apiFileBlock.text).toContain("fetchData")
+ })
+
+ it("should not include file context section when filesReadByRoo is empty", async () => {
+ const { summarizeConversation } = await import("../index")
+
+ const mockApiHandler = new MockApiHandler()
+ const taskId = "test-task-id-2"
+
+ const messages: any[] = [
+ { role: "user", content: "First message" },
+ { role: "assistant", content: "Second message" },
+ { role: "user", content: "Third message" },
+ { role: "assistant", content: "Fourth message" },
+ { role: "user", content: "Fifth message" },
+ { role: "assistant", content: "Sixth message" },
+ { role: "user", content: "Seventh message" },
+ ]
+
+ // Reset the mock to ensure clean state
+ mockedGenerateFoldedFileContext.mockClear()
+
+ const result = await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: false,
+ filesReadByRoo: [],
+ cwd: "/test/project",
+ })
+
+ // generateFoldedFileContext should NOT be called when filesReadByRoo is empty
+ expect(mockedGenerateFoldedFileContext).not.toHaveBeenCalled()
+
+ // Find the summary message
+ const summaryMessage = result.messages.find((msg: any) => msg.isSummary)
+ expect(summaryMessage).toBeDefined()
+
+ // The summary content should NOT contain any file context blocks
+ const contentArray = summaryMessage!.content as any[]
+ const fileContextBlock = contentArray.find(
+ (block: any) => block.type === "text" && block.text?.includes("## File Context"),
+ )
+ expect(fileContextBlock).toBeUndefined()
+ })
+ })
+})
diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts
index ef5af01243..75190985db 100644
--- a/src/core/condense/__tests__/index.spec.ts
+++ b/src/core/condense/__tests__/index.spec.ts
@@ -11,10 +11,10 @@ import { maybeRemoveImageBlocks } from "../../../api/transform/image-cleaning"
import {
summarizeConversation,
getMessagesSinceLastSummary,
- getKeepMessagesWithToolBlocks,
getEffectiveApiHistory,
cleanupAfterTruncation,
- N_MESSAGES_TO_KEEP,
+ extractCommandBlocks,
+ injectSyntheticToolResults,
} from "../index"
vi.mock("../../../api/transform/image-cleaning", () => ({
@@ -30,557 +30,213 @@ vi.mock("@roo-code/telemetry", () => ({
}))
const taskId = "test-task-id"
-const DEFAULT_PREV_CONTEXT_TOKENS = 1000
-describe("getKeepMessagesWithToolBlocks", () => {
- it("should return keepMessages without tool blocks when no tool_result blocks in first kept message", () => {
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Hi there", ts: 2 },
- { role: "user", content: "How are you?", ts: 3 },
- { role: "assistant", content: "I'm good", ts: 4 },
- { role: "user", content: "What's new?", ts: 5 },
- ]
+describe("extractCommandBlocks", () => {
+ it("should extract command blocks from string content", () => {
+ const message: ApiMessage = {
+ role: "user",
+ content: 'Some text /prr #123 more text',
+ }
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- expect(result.keepMessages).toHaveLength(3)
- expect(result.toolUseBlocksToPreserve).toHaveLength(0)
+ const result = extractCommandBlocks(message)
+ expect(result).toBe('/prr #123 ')
})
- it("should return all messages when messages.length <= keepCount", () => {
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Hi there", ts: 2 },
- ]
+ it("should extract multiple command blocks", () => {
+ const message: ApiMessage = {
+ role: "user",
+ content: '/prr #123 text /mode code ',
+ }
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- expect(result.keepMessages).toEqual(messages)
- expect(result.toolUseBlocksToPreserve).toHaveLength(0)
+ const result = extractCommandBlocks(message)
+ expect(result).toBe('/prr #123 \n/mode code ')
})
- it("should preserve tool_use blocks when first kept message has tool_result blocks", () => {
- const toolUseBlock = {
- type: "tool_use" as const,
- id: "toolu_123",
- name: "read_file",
- input: { path: "test.txt" },
- }
- const toolResultBlock = {
- type: "tool_result" as const,
- tool_use_id: "toolu_123",
- content: "file contents",
+ it("should extract command blocks from array content", () => {
+ const message: ApiMessage = {
+ role: "user",
+ content: [
+ { type: "text", text: "Some user text" },
+ { type: "text", text: 'Help content ' },
+ ],
}
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Let me read that file", ts: 2 },
- { role: "user", content: "Please continue", ts: 3 },
- {
- role: "assistant",
- content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock],
- ts: 4,
- },
- {
- role: "user",
- content: [toolResultBlock, { type: "text" as const, text: "Continue" }],
- ts: 5,
- },
- { role: "assistant", content: "Got it, the file says...", ts: 6 },
- { role: "user", content: "Thanks", ts: 7 },
- ]
-
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- // keepMessages should be the last 3 messages
- expect(result.keepMessages).toHaveLength(3)
- expect(result.keepMessages[0].ts).toBe(5)
- expect(result.keepMessages[1].ts).toBe(6)
- expect(result.keepMessages[2].ts).toBe(7)
-
- // Should preserve the tool_use block from the preceding assistant message
- expect(result.toolUseBlocksToPreserve).toHaveLength(1)
- expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock)
+ const result = extractCommandBlocks(message)
+ expect(result).toBe('Help content ')
})
- it("should not preserve tool_use blocks when first kept message is assistant role", () => {
- const toolUseBlock = {
- type: "tool_use" as const,
- id: "toolu_123",
- name: "read_file",
- input: { path: "test.txt" },
+ it("should return empty string when no command blocks found", () => {
+ const message: ApiMessage = {
+ role: "user",
+ content: "Just regular text without commands",
}
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Hi there", ts: 2 },
- { role: "user", content: "Please read", ts: 3 },
- {
- role: "assistant",
- content: [{ type: "text" as const, text: "Reading..." }, toolUseBlock],
- ts: 4,
- },
- { role: "user", content: "Continue", ts: 5 },
- { role: "assistant", content: "Done", ts: 6 },
- ]
-
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- // First kept message is assistant, not user with tool_result
- expect(result.keepMessages).toHaveLength(3)
- expect(result.keepMessages[0].role).toBe("assistant")
- expect(result.toolUseBlocksToPreserve).toHaveLength(0)
+ const result = extractCommandBlocks(message)
+ expect(result).toBe("")
})
- it("should not preserve tool_use blocks when first kept user message has string content", () => {
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Hi there", ts: 2 },
- { role: "user", content: "How are you?", ts: 3 },
- { role: "assistant", content: "Good", ts: 4 },
- { role: "user", content: "Simple text message", ts: 5 }, // String content, not array
- { role: "assistant", content: "Response", ts: 6 },
- { role: "user", content: "More text", ts: 7 },
- ]
+ it("should handle multiline command blocks", () => {
+ const message: ApiMessage = {
+ role: "user",
+ content: `
+Line 1
+Line 2
+ `,
+ }
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- expect(result.keepMessages).toHaveLength(3)
- expect(result.toolUseBlocksToPreserve).toHaveLength(0)
+ const result = extractCommandBlocks(message)
+ expect(result).toContain("Line 1")
+ expect(result).toContain("Line 2")
})
- it("should handle multiple tool_use blocks that need to be preserved", () => {
- const toolUseBlock1 = {
- type: "tool_use" as const,
- id: "toolu_123",
- name: "read_file",
- input: { path: "file1.txt" },
- }
- const toolUseBlock2 = {
- type: "tool_use" as const,
- id: "toolu_456",
- name: "read_file",
- input: { path: "file2.txt" },
- }
- const toolResultBlock1 = {
- type: "tool_result" as const,
- tool_use_id: "toolu_123",
- content: "contents 1",
- }
- const toolResultBlock2 = {
- type: "tool_result" as const,
- tool_use_id: "toolu_456",
- content: "contents 2",
+ it("should handle command blocks with attributes", () => {
+ const message: ApiMessage = {
+ role: "user",
+ content: 'content ',
}
+ const result = extractCommandBlocks(message)
+ expect(result).toContain('name="test"')
+ expect(result).toContain('attr1="value1"')
+ })
+})
+
+describe("injectSyntheticToolResults", () => {
+ it("should return messages unchanged when no orphan tool_calls exist", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
{
role: "assistant",
- content: [{ type: "text" as const, text: "Reading files..." }, toolUseBlock1, toolUseBlock2],
+ content: [{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.ts" } }],
ts: 2,
},
{
role: "user",
- content: [toolResultBlock1, toolResultBlock2],
+ content: [{ type: "tool_result", tool_use_id: "tool-1", content: "file contents" }],
ts: 3,
},
- { role: "assistant", content: "Got both files", ts: 4 },
- { role: "user", content: "Thanks", ts: 5 },
]
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- // Should preserve both tool_use blocks
- expect(result.toolUseBlocksToPreserve).toHaveLength(2)
- expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock1)
- expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock2)
+ const result = injectSyntheticToolResults(messages)
+ expect(result).toEqual(messages)
})
- it("should not preserve tool_use blocks when preceding message has no tool_use blocks", () => {
- const toolResultBlock = {
- type: "tool_result" as const,
- tool_use_id: "toolu_123",
- content: "file contents",
- }
-
+ it("should inject synthetic tool_result for orphan tool_call", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Plain text response", ts: 2 }, // No tool_use blocks
- {
- role: "user",
- content: [toolResultBlock], // Has tool_result but preceding message has no tool_use
- ts: 3,
- },
- { role: "assistant", content: "Response", ts: 4 },
- { role: "user", content: "Thanks", ts: 5 },
- ]
-
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- expect(result.keepMessages).toHaveLength(3)
- expect(result.toolUseBlocksToPreserve).toHaveLength(0)
- })
-
- it("should handle edge case when startIndex - 1 is negative", () => {
- const toolResultBlock = {
- type: "tool_result" as const,
- tool_use_id: "toolu_123",
- content: "file contents",
- }
-
- // Only 3 messages total, so startIndex = 0 and precedingIndex would be -1
- const messages: ApiMessage[] = [
- {
- role: "user",
- content: [toolResultBlock],
- ts: 1,
- },
- { role: "assistant", content: "Response", ts: 2 },
- { role: "user", content: "Thanks", ts: 3 },
- ]
-
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- expect(result.keepMessages).toEqual(messages)
- expect(result.toolUseBlocksToPreserve).toHaveLength(0)
- })
-
- it("should preserve reasoning blocks alongside tool_use blocks for DeepSeek/Z.ai interleaved thinking", () => {
- const reasoningBlock = {
- type: "reasoning" as const,
- text: "Let me think about this step by step...",
- }
- const toolUseBlock = {
- type: "tool_use" as const,
- id: "toolu_deepseek_123",
- name: "read_file",
- input: { path: "test.txt" },
- }
- const toolResultBlock = {
- type: "tool_result" as const,
- tool_use_id: "toolu_deepseek_123",
- content: "file contents",
- }
-
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Let me help", ts: 2 },
- { role: "user", content: "Please read the file", ts: 3 },
- {
- role: "assistant",
- // DeepSeek stores reasoning as content blocks alongside tool_use
- content: [reasoningBlock as any, { type: "text" as const, text: "Reading file..." }, toolUseBlock],
- ts: 4,
- },
- {
- role: "user",
- content: [toolResultBlock, { type: "text" as const, text: "Continue" }],
- ts: 5,
- },
- { role: "assistant", content: "Got it, the file says...", ts: 6 },
- { role: "user", content: "Thanks", ts: 7 },
- ]
-
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- // keepMessages should be the last 3 messages
- expect(result.keepMessages).toHaveLength(3)
- expect(result.keepMessages[0].ts).toBe(5)
-
- // Should preserve the tool_use block
- expect(result.toolUseBlocksToPreserve).toHaveLength(1)
- expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock)
-
- // Should preserve the reasoning block for DeepSeek/Z.ai interleaved thinking
- expect(result.reasoningBlocksToPreserve).toHaveLength(1)
- expect((result.reasoningBlocksToPreserve[0] as any).type).toBe("reasoning")
- expect((result.reasoningBlocksToPreserve[0] as any).text).toBe("Let me think about this step by step...")
- })
-
- it("should return empty reasoningBlocksToPreserve when no reasoning blocks present", () => {
- const toolUseBlock = {
- type: "tool_use" as const,
- id: "toolu_123",
- name: "read_file",
- input: { path: "test.txt" },
- }
- const toolResultBlock = {
- type: "tool_result" as const,
- tool_use_id: "toolu_123",
- content: "file contents",
- }
-
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- {
- role: "assistant",
- // No reasoning block, just text and tool_use
- content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock],
- ts: 2,
- },
- {
- role: "user",
- content: [toolResultBlock],
- ts: 3,
- },
- { role: "assistant", content: "Done", ts: 4 },
- { role: "user", content: "Thanks", ts: 5 },
- ]
-
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- expect(result.toolUseBlocksToPreserve).toHaveLength(1)
- expect(result.reasoningBlocksToPreserve).toHaveLength(0)
- })
-
- it("should preserve tool_use when tool_result is in 2nd kept message and tool_use is 2 messages before boundary", () => {
- const toolUseBlock = {
- type: "tool_use" as const,
- id: "toolu_second_kept",
- name: "read_file",
- input: { path: "test.txt" },
- }
- const toolResultBlock = {
- type: "tool_result" as const,
- tool_use_id: "toolu_second_kept",
- content: "file contents",
- }
-
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Let me help", ts: 2 },
- {
- role: "assistant",
- content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock],
- ts: 3,
- },
- { role: "user", content: "Some other message", ts: 4 },
- { role: "assistant", content: "First kept message", ts: 5 },
- {
- role: "user",
- content: [toolResultBlock, { type: "text" as const, text: "Continue" }],
- ts: 6,
- },
- { role: "assistant", content: "Third kept message", ts: 7 },
- ]
-
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- // keepMessages should be the last 3 messages (ts: 5, 6, 7)
- expect(result.keepMessages).toHaveLength(3)
- expect(result.keepMessages[0].ts).toBe(5)
- expect(result.keepMessages[1].ts).toBe(6)
- expect(result.keepMessages[2].ts).toBe(7)
-
- // Should preserve the tool_use block from message at ts:3 (2 messages before boundary)
- expect(result.toolUseBlocksToPreserve).toHaveLength(1)
- expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock)
- })
-
- it("should preserve tool_use when tool_result is in 3rd kept message and tool_use is at boundary edge", () => {
- const toolUseBlock = {
- type: "tool_use" as const,
- id: "toolu_third_kept",
- name: "search",
- input: { query: "test" },
- }
- const toolResultBlock = {
- type: "tool_result" as const,
- tool_use_id: "toolu_third_kept",
- content: "search results",
- }
-
- const messages: ApiMessage[] = [
- { role: "user", content: "Start", ts: 1 },
- {
- role: "assistant",
- content: [{ type: "text" as const, text: "Searching..." }, toolUseBlock],
- ts: 2,
- },
- { role: "user", content: "First kept message", ts: 3 },
- { role: "assistant", content: "Second kept message", ts: 4 },
- {
- role: "user",
- content: [toolResultBlock, { type: "text" as const, text: "Done" }],
- ts: 5,
- },
- ]
-
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- // keepMessages should be the last 3 messages (ts: 3, 4, 5)
- expect(result.keepMessages).toHaveLength(3)
- expect(result.keepMessages[0].ts).toBe(3)
- expect(result.keepMessages[1].ts).toBe(4)
- expect(result.keepMessages[2].ts).toBe(5)
-
- // Should preserve the tool_use block from message at ts:2 (at the search boundary edge)
- expect(result.toolUseBlocksToPreserve).toHaveLength(1)
- expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock)
- })
-
- it("should preserve multiple tool_uses when tool_results are in different kept messages", () => {
- const toolUseBlock1 = {
- type: "tool_use" as const,
- id: "toolu_multi_1",
- name: "read_file",
- input: { path: "file1.txt" },
- }
- const toolUseBlock2 = {
- type: "tool_use" as const,
- id: "toolu_multi_2",
- name: "read_file",
- input: { path: "file2.txt" },
- }
- const toolResultBlock1 = {
- type: "tool_result" as const,
- tool_use_id: "toolu_multi_1",
- content: "contents 1",
- }
- const toolResultBlock2 = {
- type: "tool_result" as const,
- tool_use_id: "toolu_multi_2",
- content: "contents 2",
- }
-
- const messages: ApiMessage[] = [
- { role: "user", content: "Start", ts: 1 },
- {
- role: "assistant",
- content: [{ type: "text" as const, text: "Reading file 1..." }, toolUseBlock1],
- ts: 2,
- },
- { role: "user", content: "Some message", ts: 3 },
- {
- role: "assistant",
- content: [{ type: "text" as const, text: "Reading file 2..." }, toolUseBlock2],
- ts: 4,
- },
- {
- role: "user",
- content: [toolResultBlock1, { type: "text" as const, text: "First result" }],
- ts: 5,
- },
- {
- role: "user",
- content: [toolResultBlock2, { type: "text" as const, text: "Second result" }],
- ts: 6,
- },
- { role: "assistant", content: "Got both files", ts: 7 },
- ]
-
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- // keepMessages should be the last 3 messages (ts: 5, 6, 7)
- expect(result.keepMessages).toHaveLength(3)
-
- // Should preserve both tool_use blocks
- expect(result.toolUseBlocksToPreserve).toHaveLength(2)
- expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock1)
- expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock2)
- })
-
- it("should not crash when tool_result references tool_use beyond search boundary", () => {
- const toolResultBlock = {
- type: "tool_result" as const,
- tool_use_id: "toolu_beyond_boundary",
- content: "result",
- }
-
- // Tool_use is at ts:1, but with N_MESSAGES_TO_KEEP=3, we only search back 3 messages
- // from startIndex-1. StartIndex is 7 (messages.length=10, keepCount=3, startIndex=7).
- // So we search from index 6 down to index 4 (7-1 down to 7-3).
- // The tool_use at index 0 (ts:1) is beyond the search boundary.
- const messages: ApiMessage[] = [
{
role: "assistant",
content: [
- { type: "text" as const, text: "Way back..." },
- {
- type: "tool_use" as const,
- id: "toolu_beyond_boundary",
- name: "old_tool",
- input: {},
- },
+ { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } },
],
- ts: 1,
+ ts: 2,
},
- { role: "user", content: "Message 2", ts: 2 },
- { role: "assistant", content: "Message 3", ts: 3 },
- { role: "user", content: "Message 4", ts: 4 },
- { role: "assistant", content: "Message 5", ts: 5 },
- { role: "user", content: "Message 6", ts: 6 },
- { role: "assistant", content: "Message 7", ts: 7 },
- {
- role: "user",
- content: [toolResultBlock],
- ts: 8,
- },
- { role: "assistant", content: "Message 9", ts: 9 },
- { role: "user", content: "Message 10", ts: 10 },
+ // No tool_result for tool-orphan
]
- // Should not crash
- const result = getKeepMessagesWithToolBlocks(messages, 3)
+ const result = injectSyntheticToolResults(messages)
- // keepMessages should be the last 3 messages
- expect(result.keepMessages).toHaveLength(3)
- expect(result.keepMessages[0].ts).toBe(8)
- expect(result.keepMessages[1].ts).toBe(9)
- expect(result.keepMessages[2].ts).toBe(10)
+ expect(result.length).toBe(3)
+ expect(result[2].role).toBe("user")
- // Should not preserve the tool_use since it's beyond the search boundary
- expect(result.toolUseBlocksToPreserve).toHaveLength(0)
+ const content = result[2].content as any[]
+ expect(content.length).toBe(1)
+ expect(content[0].type).toBe("tool_result")
+ expect(content[0].tool_use_id).toBe("tool-orphan")
+ expect(content[0].content).toBe("Context condensation triggered. Tool execution deferred.")
})
- it("should not duplicate tool_use blocks when same tool_result ID appears multiple times", () => {
- const toolUseBlock = {
- type: "tool_use" as const,
- id: "toolu_duplicate",
- name: "read_file",
- input: { path: "test.txt" },
- }
- const toolResultBlock1 = {
- type: "tool_result" as const,
- tool_use_id: "toolu_duplicate",
- content: "result 1",
- }
- const toolResultBlock2 = {
- type: "tool_result" as const,
- tool_use_id: "toolu_duplicate",
- content: "result 2",
- }
-
+ it("should inject synthetic tool_results for multiple orphan tool_calls", () => {
const messages: ApiMessage[] = [
- { role: "user", content: "Start", ts: 1 },
+ { role: "user", content: "Hello", ts: 1 },
{
role: "assistant",
- content: [{ type: "text" as const, text: "Using tool..." }, toolUseBlock],
+ content: [
+ { type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.ts" } },
+ { type: "tool_use", id: "tool-2", name: "write_file", input: { path: "out.ts", content: "code" } },
+ ],
+ ts: 2,
+ },
+ // No tool_results for either
+ ]
+
+ const result = injectSyntheticToolResults(messages)
+
+ expect(result.length).toBe(3)
+ const content = result[2].content as any[]
+ expect(content.length).toBe(2)
+ expect(content[0].tool_use_id).toBe("tool-1")
+ expect(content[1].tool_use_id).toBe("tool-2")
+ })
+
+ it("should only inject for orphan tool_calls, not matched ones", () => {
+ const messages: ApiMessage[] = [
+ { role: "user", content: "Hello", ts: 1 },
+ {
+ role: "assistant",
+ content: [
+ { type: "tool_use", id: "matched-tool", name: "read_file", input: { path: "test.ts" } },
+ { type: "tool_use", id: "orphan-tool", name: "attempt_completion", input: { result: "Done" } },
+ ],
ts: 2,
},
{
role: "user",
- content: [toolResultBlock1],
+ content: [{ type: "tool_result", tool_use_id: "matched-tool", content: "file contents" }],
ts: 3,
},
- { role: "assistant", content: "Processing", ts: 4 },
+ // No tool_result for orphan-tool
+ ]
+
+ const result = injectSyntheticToolResults(messages)
+
+ expect(result.length).toBe(4)
+ const syntheticContent = result[3].content as any[]
+ expect(syntheticContent.length).toBe(1)
+ expect(syntheticContent[0].tool_use_id).toBe("orphan-tool")
+ })
+
+ it("should handle messages with string content (no tool_use/tool_result)", () => {
+ const messages: ApiMessage[] = [
+ { role: "user", content: "Hello", ts: 1 },
+ { role: "assistant", content: "Hi there!", ts: 2 },
+ ]
+
+ const result = injectSyntheticToolResults(messages)
+ expect(result).toEqual(messages)
+ })
+
+ it("should handle empty messages array", () => {
+ const result = injectSyntheticToolResults([])
+ expect(result).toEqual([])
+ })
+
+ it("should handle tool_results spread across multiple user messages", () => {
+ const messages: ApiMessage[] = [
+ { role: "user", content: "Hello", ts: 1 },
+ {
+ role: "assistant",
+ content: [
+ { type: "tool_use", id: "tool-1", name: "read_file", input: { path: "a.ts" } },
+ { type: "tool_use", id: "tool-2", name: "read_file", input: { path: "b.ts" } },
+ ],
+ ts: 2,
+ },
{
role: "user",
- content: [toolResultBlock2], // Same tool_use_id as first result
- ts: 5,
+ content: [{ type: "tool_result", tool_use_id: "tool-1", content: "contents a" }],
+ ts: 3,
+ },
+ {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: "tool-2", content: "contents b" }],
+ ts: 4,
},
]
- const result = getKeepMessagesWithToolBlocks(messages, 3)
-
- // keepMessages should be the last 3 messages (ts: 3, 4, 5)
- expect(result.keepMessages).toHaveLength(3)
-
- // Should only preserve the tool_use block once, not twice
- expect(result.toolUseBlocksToPreserve).toHaveLength(1)
- expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock)
+ const result = injectSyntheticToolResults(messages)
+ // Both tool_uses have matching tool_results, no injection needed
+ expect(result).toEqual(messages)
})
})
@@ -596,38 +252,36 @@ describe("getMessagesSinceLastSummary", () => {
expect(result).toEqual(messages)
})
- it("should return messages since the last summary with original first user message", () => {
+ it("should return messages since the last summary", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
{ role: "assistant", content: "Hi there", ts: 2 },
- { role: "assistant", content: "Summary of conversation", ts: 3, isSummary: true },
- { role: "user", content: "How are you?", ts: 4 },
- { role: "assistant", content: "I'm good", ts: 5 },
+ { role: "user", content: "Summary of conversation", ts: 3, isSummary: true },
+ { role: "assistant", content: "How are you?", ts: 4 },
+ { role: "user", content: "I'm good", ts: 5 },
]
const result = getMessagesSinceLastSummary(messages)
expect(result).toEqual([
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Summary of conversation", ts: 3, isSummary: true },
- { role: "user", content: "How are you?", ts: 4 },
- { role: "assistant", content: "I'm good", ts: 5 },
+ { role: "user", content: "Summary of conversation", ts: 3, isSummary: true },
+ { role: "assistant", content: "How are you?", ts: 4 },
+ { role: "user", content: "I'm good", ts: 5 },
])
})
- it("should handle multiple summary messages and return since the last one with original first user message", () => {
+ it("should handle multiple summary messages and return since the last one", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "First summary", ts: 2, isSummary: true },
- { role: "user", content: "How are you?", ts: 3 },
- { role: "assistant", content: "Second summary", ts: 4, isSummary: true },
- { role: "user", content: "What's new?", ts: 5 },
+ { role: "user", content: "First summary", ts: 2, isSummary: true },
+ { role: "assistant", content: "How are you?", ts: 3 },
+ { role: "user", content: "Second summary", ts: 4, isSummary: true },
+ { role: "assistant", content: "What's new?", ts: 5 },
]
const result = getMessagesSinceLastSummary(messages)
expect(result).toEqual([
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Second summary", ts: 4, isSummary: true },
- { role: "user", content: "What's new?", ts: 5 },
+ { role: "user", content: "Second summary", ts: 4, isSummary: true },
+ { role: "assistant", content: "What's new?", ts: 5 },
])
})
@@ -635,6 +289,383 @@ describe("getMessagesSinceLastSummary", () => {
const result = getMessagesSinceLastSummary([])
expect(result).toEqual([])
})
+
+ it("should return messages from user summary (fresh start model)", () => {
+ const messages: ApiMessage[] = [
+ { role: "user", content: "Hello", ts: 1, condenseParent: "cond-1" },
+ { role: "assistant", content: "Hi there", ts: 2, condenseParent: "cond-1" },
+ { role: "user", content: "Summary content", ts: 3, isSummary: true, condenseId: "cond-1" },
+ { role: "assistant", content: "Response after summary", ts: 4 },
+ ]
+
+ const result = getMessagesSinceLastSummary(messages)
+ expect(result[0].isSummary).toBe(true)
+ expect(result[0].role).toBe("user")
+ })
+})
+
+describe("getEffectiveApiHistory", () => {
+ it("should return only summary when summary exists (fresh start model)", () => {
+ const condenseId = "test-condense-id"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", condenseParent: condenseId },
+ { role: "assistant", content: "Second", condenseParent: condenseId },
+ { role: "user", content: "Third", condenseParent: condenseId },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary content" }],
+ isSummary: true,
+ condenseId,
+ },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ expect(result).toHaveLength(1)
+ expect(result[0].isSummary).toBe(true)
+ })
+
+ it("should include messages after summary in fresh start model", () => {
+ const condenseId = "test-condense-id"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", condenseParent: condenseId },
+ { role: "assistant", content: "Second", condenseParent: condenseId },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary content" }],
+ isSummary: true,
+ condenseId,
+ },
+ { role: "assistant", content: "New response after summary" },
+ { role: "user", content: "New user message" },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ expect(result).toHaveLength(3)
+ expect(result[0].isSummary).toBe(true)
+ expect(result[1].content).toBe("New response after summary")
+ expect(result[2].content).toBe("New user message")
+ })
+
+ it("should return all messages when no summary exists", () => {
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First" },
+ { role: "assistant", content: "Second" },
+ { role: "user", content: "Third" },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ expect(result).toEqual(messages)
+ })
+
+ it("should restore messages when summary is deleted (rewind - orphaned condenseParent)", () => {
+ const orphanedCondenseId = "deleted-summary-id"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", condenseParent: orphanedCondenseId },
+ { role: "assistant", content: "Second", condenseParent: orphanedCondenseId },
+ { role: "user", content: "Third", condenseParent: orphanedCondenseId },
+ // Summary was deleted - no isSummary message exists
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ // With no summary, all messages should be included (orphaned condenseParent is ignored)
+ expect(result).toHaveLength(3)
+ })
+
+ it("should filter out truncated messages within summary range", () => {
+ const condenseId = "cond-1"
+ const truncationId = "trunc-1"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", condenseParent: condenseId },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary" }],
+ isSummary: true,
+ condenseId,
+ },
+ { role: "assistant", content: "Response", truncationParent: truncationId },
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "..." }],
+ isTruncationMarker: true,
+ truncationId,
+ },
+ { role: "user", content: "After truncation" },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ // Summary + truncation marker + after truncation (the truncated response is filtered out)
+ expect(result).toHaveLength(3)
+ expect(result[0].isSummary).toBe(true)
+ expect(result[1].isTruncationMarker).toBe(true)
+ expect(result[2].content).toBe("After truncation")
+ })
+
+ it("should filter out orphan tool_result blocks after fresh start condensation", () => {
+ const condenseId = "cond-1"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "Hello", condenseParent: condenseId },
+ {
+ role: "assistant",
+ content: [
+ { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } },
+ ],
+ condenseParent: condenseId,
+ },
+ // Summary comes after the tool_use (so tool_use is condensed away)
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary content" }],
+ isSummary: true,
+ condenseId,
+ },
+ // This tool_result references a tool_use that was condensed away (orphan!)
+ {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: "tool-orphan", content: "Rejected by user" }],
+ },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ // Should only return the summary, orphan tool_result message should be filtered out
+ expect(result).toHaveLength(1)
+ expect(result[0].isSummary).toBe(true)
+ })
+
+ it("should keep tool_result blocks that have matching tool_use in fresh start", () => {
+ const condenseId = "cond-1"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "Hello", condenseParent: condenseId },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary content" }],
+ isSummary: true,
+ condenseId,
+ },
+ // This tool_use is AFTER the summary, so it's not condensed away
+ {
+ role: "assistant",
+ content: [{ type: "tool_use", id: "tool-valid", name: "read_file", input: { path: "test.ts" } }],
+ },
+ // This tool_result has a matching tool_use, so it should be kept
+ {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: "tool-valid", content: "file contents" }],
+ },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ // All messages after summary should be included
+ expect(result).toHaveLength(3)
+ expect(result[0].isSummary).toBe(true)
+ expect((result[1].content as any[])[0].id).toBe("tool-valid")
+ expect((result[2].content as any[])[0].tool_use_id).toBe("tool-valid")
+ })
+
+ it("should filter orphan tool_results but keep other content in mixed user message", () => {
+ const condenseId = "cond-1"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "Hello", condenseParent: condenseId },
+ {
+ role: "assistant",
+ content: [
+ { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } },
+ ],
+ condenseParent: condenseId,
+ },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary content" }],
+ isSummary: true,
+ condenseId,
+ },
+ // This tool_use is AFTER the summary
+ {
+ role: "assistant",
+ content: [{ type: "tool_use", id: "tool-valid", name: "read_file", input: { path: "test.ts" } }],
+ },
+ // Mixed content: one orphan tool_result and one valid tool_result
+ {
+ role: "user",
+ content: [
+ { type: "tool_result", tool_use_id: "tool-orphan", content: "Orphan result" },
+ { type: "tool_result", tool_use_id: "tool-valid", content: "Valid result" },
+ ],
+ },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ // Summary + assistant with tool_use + filtered user message
+ expect(result).toHaveLength(3)
+ expect(result[0].isSummary).toBe(true)
+ // The user message should only contain the valid tool_result
+ const userContent = result[2].content as any[]
+ expect(userContent).toHaveLength(1)
+ expect(userContent[0].tool_use_id).toBe("tool-valid")
+ })
+
+ it("should handle multiple orphan tool_results in a single message", () => {
+ const condenseId = "cond-1"
+ const messages: ApiMessage[] = [
+ {
+ role: "assistant",
+ content: [
+ { type: "tool_use", id: "orphan-1", name: "read_file", input: { path: "a.ts" } },
+ { type: "tool_use", id: "orphan-2", name: "write_file", input: { path: "b.ts", content: "code" } },
+ ],
+ condenseParent: condenseId,
+ },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary content" }],
+ isSummary: true,
+ condenseId,
+ },
+ // Multiple orphan tool_results - entire message should be removed
+ {
+ role: "user",
+ content: [
+ { type: "tool_result", tool_use_id: "orphan-1", content: "Result 1" },
+ { type: "tool_result", tool_use_id: "orphan-2", content: "Result 2" },
+ ],
+ },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ // Only summary should remain
+ expect(result).toHaveLength(1)
+ expect(result[0].isSummary).toBe(true)
+ })
+
+ it("should preserve non-tool_result content in user messages", () => {
+ const condenseId = "cond-1"
+ const messages: ApiMessage[] = [
+ {
+ role: "assistant",
+ content: [
+ { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } },
+ ],
+ condenseParent: condenseId,
+ },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary content" }],
+ isSummary: true,
+ condenseId,
+ },
+ // User message with text content and orphan tool_result
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "User added some text" },
+ { type: "tool_result", tool_use_id: "tool-orphan", content: "Orphan result" },
+ ],
+ },
+ ]
+
+ const result = getEffectiveApiHistory(messages)
+
+ // Summary + user message with only text (orphan tool_result filtered)
+ expect(result).toHaveLength(2)
+ expect(result[0].isSummary).toBe(true)
+ const userContent = result[1].content as any[]
+ expect(userContent).toHaveLength(1)
+ expect(userContent[0].type).toBe("text")
+ expect(userContent[0].text).toBe("User added some text")
+ })
+})
+
+describe("cleanupAfterTruncation", () => {
+ it("should clear orphaned condenseParent references", () => {
+ const orphanedCondenseId = "deleted-summary"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", condenseParent: orphanedCondenseId },
+ { role: "assistant", content: "Second", condenseParent: orphanedCondenseId },
+ { role: "user", content: "Third" },
+ ]
+
+ const result = cleanupAfterTruncation(messages)
+
+ expect(result[0].condenseParent).toBeUndefined()
+ expect(result[1].condenseParent).toBeUndefined()
+ expect(result[2].condenseParent).toBeUndefined()
+ })
+
+ it("should keep condenseParent when summary still exists", () => {
+ const condenseId = "existing-summary"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", condenseParent: condenseId },
+ { role: "assistant", content: "Second", condenseParent: condenseId },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary" }],
+ isSummary: true,
+ condenseId,
+ },
+ ]
+
+ const result = cleanupAfterTruncation(messages)
+
+ expect(result[0].condenseParent).toBe(condenseId)
+ expect(result[1].condenseParent).toBe(condenseId)
+ })
+
+ it("should clear orphaned truncationParent references", () => {
+ const orphanedTruncationId = "deleted-truncation"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", truncationParent: orphanedTruncationId },
+ { role: "assistant", content: "Second" },
+ ]
+
+ const result = cleanupAfterTruncation(messages)
+
+ expect(result[0].truncationParent).toBeUndefined()
+ })
+
+ it("should keep truncationParent when marker still exists", () => {
+ const truncationId = "existing-truncation"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", truncationParent: truncationId },
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "..." }],
+ isTruncationMarker: true,
+ truncationId,
+ },
+ ]
+
+ const result = cleanupAfterTruncation(messages)
+
+ expect(result[0].truncationParent).toBe(truncationId)
+ })
+
+ it("should handle mixed orphaned and valid references", () => {
+ const validCondenseId = "valid-cond"
+ const orphanedCondenseId = "orphaned-cond"
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First", condenseParent: orphanedCondenseId },
+ { role: "assistant", content: "Second", condenseParent: validCondenseId },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary" }],
+ isSummary: true,
+ condenseId: validCondenseId,
+ },
+ ]
+
+ const result = cleanupAfterTruncation(messages)
+
+ expect(result[0].condenseParent).toBeUndefined() // orphaned, cleared
+ expect(result[1].condenseParent).toBe(validCondenseId) // valid, kept
+ })
})
describe("summarizeConversation", () => {
@@ -677,18 +708,14 @@ describe("summarizeConversation", () => {
const defaultSystemPrompt = "You are a helpful assistant."
it("should not summarize when there are not enough messages", async () => {
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Hi there", ts: 2 },
- ]
+ const messages: ApiMessage[] = [{ role: "user", content: "Hello", ts: 1 }]
- const result = await summarizeConversation(
+ const result = await summarizeConversation({
messages,
- mockApiHandler,
- defaultSystemPrompt,
+ apiHandler: mockApiHandler,
+ systemPrompt: defaultSystemPrompt,
taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- )
+ })
expect(result.messages).toEqual(messages)
expect(result.cost).toBe(0)
expect(result.summary).toBe("")
@@ -697,33 +724,7 @@ describe("summarizeConversation", () => {
expect(mockApiHandler.createMessage).not.toHaveBeenCalled()
})
- it("should not summarize when there was a recent summary", async () => {
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Hi there", ts: 2 },
- { role: "user", content: "How are you?", ts: 3 },
- { role: "assistant", content: "I'm good", ts: 4 },
- { role: "user", content: "What's new?", ts: 5 },
- { role: "assistant", content: "Not much", ts: 6, isSummary: true }, // Recent summary
- { role: "user", content: "Tell me more", ts: 7 },
- ]
-
- const result = await summarizeConversation(
- messages,
- mockApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- )
- expect(result.messages).toEqual(messages)
- expect(result.cost).toBe(0)
- expect(result.summary).toBe("")
- expect(result.newContextTokens).toBeUndefined()
- expect(result.error).toBeTruthy() // Error should be set for recent summary
- expect(mockApiHandler.createMessage).not.toHaveBeenCalled()
- })
-
- it("should summarize conversation and insert summary message", async () => {
+ it("should create summary with user role (fresh start model)", async () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
{ role: "assistant", content: "Hi there", ts: 2 },
@@ -734,55 +735,108 @@ describe("summarizeConversation", () => {
{ role: "user", content: "Tell me more", ts: 7 },
]
- const result = await summarizeConversation(
+ const result = await summarizeConversation({
messages,
- mockApiHandler,
- defaultSystemPrompt,
+ apiHandler: mockApiHandler,
+ systemPrompt: defaultSystemPrompt,
taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- )
+ })
// Check that the API was called correctly
expect(mockApiHandler.createMessage).toHaveBeenCalled()
expect(maybeRemoveImageBlocks).toHaveBeenCalled()
- // With non-destructive condensing, the result contains ALL original messages
- // plus the summary message. Condensed messages are tagged but not deleted.
- // Use getEffectiveApiHistory to verify the effective API view matches the old behavior.
- expect(result.messages.length).toBe(messages.length + 1) // All original messages + summary
+ // Result contains all original messages (tagged) plus summary at end
+ expect(result.messages.length).toBe(messages.length + 1)
- // Check that the first message is preserved
- expect(result.messages[0]).toEqual(messages[0])
-
- // Find the summary message (it has isSummary: true)
+ // All original messages should be tagged with condenseParent
const summaryMessage = result.messages.find((m) => m.isSummary)
expect(summaryMessage).toBeDefined()
- expect(summaryMessage!.role).toBe("assistant")
- // Summary content is now always an array with [synthetic reasoning, text]
- // for DeepSeek-reasoner compatibility (requires reasoning_content on all assistant messages)
+ const condenseId = summaryMessage!.condenseId
+ expect(condenseId).toBeDefined()
+ for (const msg of result.messages.filter((m) => !m.isSummary)) {
+ expect(msg.condenseParent).toBe(condenseId)
+ }
+
+ // Summary message is a user message with just text (fresh start model)
+ expect(summaryMessage!.role).toBe("user")
expect(Array.isArray(summaryMessage!.content)).toBe(true)
const content = summaryMessage!.content as any[]
- expect(content).toHaveLength(2)
- expect(content[0].type).toBe("reasoning")
- expect(content[1].type).toBe("text")
- expect(content[1].text).toBe("This is a summary")
- expect(summaryMessage!.isSummary).toBe(true)
+ expect(content).toHaveLength(1)
+ expect(content[0].type).toBe("text")
+ expect(content[0].text).toContain("## Conversation Summary")
+ expect(content[0].text).toContain("This is a summary")
- // Verify that the effective API history matches expected: first + summary + last N messages
+ // Fresh start: effective API history should contain only the summary
const effectiveHistory = getEffectiveApiHistory(result.messages)
- expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // First + summary + last N
-
- // Check that condensed messages are properly tagged
- const condensedMessages = result.messages.filter((m) => m.condenseParent !== undefined)
- expect(condensedMessages.length).toBeGreaterThan(0)
+ expect(effectiveHistory).toHaveLength(1)
+ expect(effectiveHistory[0].isSummary).toBe(true)
+ expect(effectiveHistory[0].role).toBe("user")
// Check the cost and token counts
expect(result.cost).toBe(0.05)
expect(result.summary).toBe("This is a summary")
- expect(result.newContextTokens).toBe(250) // 150 output tokens + 100 from countTokens
+ // newContextTokens = countTokens(systemPrompt + summaryMessage) - counts actual content, not outputTokens
+ expect(result.newContextTokens).toBe(100) // countTokens mock returns 100
expect(result.error).toBeUndefined()
})
+ it("should preserve command blocks from first message in summary", async () => {
+ const messages: ApiMessage[] = [
+ {
+ role: "user",
+ content: 'Hello /prr #123 ',
+ ts: 1,
+ },
+ { role: "assistant", content: "Hi there", ts: 2 },
+ { role: "user", content: "How are you?", ts: 3 },
+ { role: "assistant", content: "I'm good", ts: 4 },
+ { role: "user", content: "What's new?", ts: 5 },
+ ]
+
+ const result = await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: defaultSystemPrompt,
+ taskId,
+ })
+
+ const summaryMessage = result.messages.find((m) => m.isSummary)
+ expect(summaryMessage).toBeDefined()
+
+ const content = summaryMessage!.content as any[]
+ // Summary content is now split into separate text blocks
+ expect(content).toHaveLength(2)
+ expect(content[0].text).toContain("## Conversation Summary")
+ expect(content[1].text).toContain("")
+ expect(content[1].text).toContain("Active Workflows")
+ expect(content[1].text).toContain('')
+ })
+
+ it("should not include command blocks wrapper when no commands in first message", async () => {
+ const messages: ApiMessage[] = [
+ { role: "user", content: "Hello", ts: 1 },
+ { role: "assistant", content: "Hi there", ts: 2 },
+ { role: "user", content: "How are you?", ts: 3 },
+ { role: "assistant", content: "I'm good", ts: 4 },
+ { role: "user", content: "What's new?", ts: 5 },
+ ]
+
+ const result = await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: defaultSystemPrompt,
+ taskId,
+ })
+
+ const summaryMessage = result.messages.find((m) => m.isSummary)
+ expect(summaryMessage).toBeDefined()
+
+ const content = summaryMessage!.content as any[]
+ expect(content[0].text).not.toContain("")
+ expect(content[0].text).not.toContain("Active Workflows")
+ })
+
it("should handle empty summary response and return error", async () => {
// We need enough messages to trigger summarization
const messages: ApiMessage[] = [
@@ -810,13 +864,12 @@ describe("summarizeConversation", () => {
return messages.map(({ role, content }: { role: string; content: any }) => ({ role, content }))
})
- const result = await summarizeConversation(
+ const result = await summarizeConversation({
messages,
- mockApiHandler,
- defaultSystemPrompt,
+ apiHandler: mockApiHandler,
+ systemPrompt: defaultSystemPrompt,
taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- )
+ })
// Should return original messages when summary is empty
expect(result.messages).toEqual(messages)
@@ -837,24 +890,32 @@ describe("summarizeConversation", () => {
{ role: "user", content: "Tell me more", ts: 7 },
]
- await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId, DEFAULT_PREV_CONTEXT_TOKENS)
+ await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: defaultSystemPrompt,
+ taskId,
+ })
- // Verify the final request message
- const expectedFinalMessage = {
- role: "user",
- content: "Summarize the conversation so far, as described in the prompt instructions.",
- }
-
- // Verify that createMessage was called with the correct prompt
+ // Verify that createMessage was called with the SUMMARY_PROMPT (which contains CRITICAL instructions), messages array, and optional metadata
expect(mockApiHandler.createMessage).toHaveBeenCalledWith(
- expect.stringContaining("Your task is to create a detailed summary of the conversation"),
+ expect.stringContaining("You are a helpful AI assistant tasked with summarizing conversations."),
expect.any(Array),
+ undefined, // metadata is undefined when not passed to summarizeConversation
)
+ // Verify the CRITICAL instructions are included in the prompt
+ const actualPrompt = (mockApiHandler.createMessage as Mock).mock.calls[0][0]
+ expect(actualPrompt).toContain("CRITICAL: This is a summarization-only request")
+ expect(actualPrompt).toContain("CRITICAL: This summarization request is a SYSTEM OPERATION")
// Check that maybeRemoveImageBlocks was called with the correct messages
+ // The final request message now contains the detailed CONDENSE instructions
const mockCallArgs = (maybeRemoveImageBlocks as Mock).mock.calls[0][0] as any[]
- expect(mockCallArgs[mockCallArgs.length - 1]).toEqual(expectedFinalMessage)
+ const finalMessage = mockCallArgs[mockCallArgs.length - 1]
+ expect(finalMessage.role).toBe("user")
+ expect(finalMessage.content).toContain("Your task is to create a detailed summary of the conversation")
})
+
it("should include the original first user message in summarization input", async () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Initial ask", ts: 1 },
@@ -866,7 +927,12 @@ describe("summarizeConversation", () => {
{ role: "user", content: "Newest", ts: 7 },
]
- await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId, DEFAULT_PREV_CONTEXT_TOKENS)
+ await summarizeConversation({
+ messages,
+ apiHandler: mockApiHandler,
+ systemPrompt: defaultSystemPrompt,
+ taskId,
+ })
const mockCallArgs = (maybeRemoveImageBlocks as Mock).mock.calls[0][0] as any[]
@@ -904,66 +970,24 @@ describe("summarizeConversation", () => {
// Override the mock for this test
mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any
- const result = await summarizeConversation(
+ const result = await summarizeConversation({
messages,
- mockApiHandler,
+ apiHandler: mockApiHandler,
systemPrompt,
taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- )
+ })
- // Verify that countTokens was called with the correct messages including system prompt
+ // Verify that countTokens was called with system prompt + summary message
expect(mockApiHandler.countTokens).toHaveBeenCalled()
- // Check the newContextTokens calculation includes system prompt
- expect(result.newContextTokens).toBe(300) // 200 output tokens + 100 from countTokens
+ // newContextTokens = countTokens(systemPrompt + summaryMessage) - counts actual content
+ expect(result.newContextTokens).toBe(100) // countTokens mock returns 100
expect(result.cost).toBe(0.06)
expect(result.summary).toBe("This is a summary with system prompt")
expect(result.error).toBeUndefined()
})
- it("should return error when new context tokens >= previous context tokens", async () => {
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Hi there", ts: 2 },
- { role: "user", content: "How are you?", ts: 3 },
- { role: "assistant", content: "I'm good", ts: 4 },
- { role: "user", content: "What's new?", ts: 5 },
- { role: "assistant", content: "Not much", ts: 6 },
- { role: "user", content: "Tell me more", ts: 7 },
- ]
-
- // Create a stream that produces a summary
- const streamWithLargeTokens = (async function* () {
- yield { type: "text" as const, text: "This is a very long summary that uses many tokens" }
- yield { type: "usage" as const, totalCost: 0.08, outputTokens: 500 }
- })()
-
- // Override the mock for this test
- mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithLargeTokens) as any
-
- // Mock countTokens to return a high value that when added to outputTokens (500)
- // will be >= prevContextTokens (600)
- mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(200)) as any
-
- const prevContextTokens = 600
- const result = await summarizeConversation(
- messages,
- mockApiHandler,
- defaultSystemPrompt,
- taskId,
- prevContextTokens,
- )
-
- // Should return original messages when context would grow
- expect(result.messages).toEqual(messages)
- expect(result.cost).toBe(0.08)
- expect(result.summary).toBe("")
- expect(result.error).toBeTruthy() // Error should be set
- expect(result.newContextTokens).toBeUndefined()
- })
-
- it("should successfully summarize when new context tokens < previous context tokens", async () => {
+ it("should successfully summarize conversation", async () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
{ role: "assistant", content: "Hi there", ts: 2 },
@@ -983,79 +1007,32 @@ describe("summarizeConversation", () => {
// Override the mock for this test
mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithSmallTokens) as any
- // Mock countTokens to return a small value so total is < prevContextTokens
+ // Mock countTokens to return a small value
mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(30)) as any
- const prevContextTokens = 200
- const result = await summarizeConversation(
+ const result = await summarizeConversation({
messages,
- mockApiHandler,
- defaultSystemPrompt,
+ apiHandler: mockApiHandler,
+ systemPrompt: defaultSystemPrompt,
taskId,
- prevContextTokens,
- )
+ })
- // With non-destructive condensing, result contains all messages plus summary
- // Use getEffectiveApiHistory to verify the effective API view
- expect(result.messages.length).toBe(messages.length + 1) // All messages + summary
+ // Result contains all messages plus summary
+ expect(result.messages.length).toBe(messages.length + 1)
+
+ // Fresh start: effective history should contain only the summary
const effectiveHistory = getEffectiveApiHistory(result.messages)
- expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // First + summary + last N
+ expect(effectiveHistory.length).toBe(1)
+ expect(effectiveHistory[0].isSummary).toBe(true)
+
expect(result.cost).toBe(0.03)
expect(result.summary).toBe("Concise summary")
expect(result.error).toBeUndefined()
- expect(result.newContextTokens).toBe(80) // 50 output tokens + 30 from countTokens
- expect(result.newContextTokens).toBeLessThan(prevContextTokens)
+ // newContextTokens = countTokens(systemPrompt + summaryMessage) - counts actual content
+ expect(result.newContextTokens).toBe(30) // countTokens mock returns 30
})
- it("should return error when not enough messages to summarize", async () => {
- const messages: ApiMessage[] = [{ role: "user", content: "Hello", ts: 1 }]
-
- const result = await summarizeConversation(
- messages,
- mockApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- )
-
- // Should return original messages when not enough to summarize
- expect(result.messages).toEqual(messages)
- expect(result.cost).toBe(0)
- expect(result.summary).toBe("")
- expect(result.error).toBeTruthy() // Error should be set
- expect(result.newContextTokens).toBeUndefined()
- expect(mockApiHandler.createMessage).not.toHaveBeenCalled()
- })
-
- it("should return error when recent summary exists in kept messages", async () => {
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Hi there", ts: 2 },
- { role: "user", content: "How are you?", ts: 3 },
- { role: "assistant", content: "I'm good", ts: 4 },
- { role: "user", content: "What's new?", ts: 5 },
- { role: "assistant", content: "Recent summary", ts: 6, isSummary: true }, // Summary in last 3 messages
- { role: "user", content: "Tell me more", ts: 7 },
- ]
-
- const result = await summarizeConversation(
- messages,
- mockApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- )
-
- // Should return original messages when recent summary exists
- expect(result.messages).toEqual(messages)
- expect(result.cost).toBe(0)
- expect(result.summary).toBe("")
- expect(result.error).toBeTruthy() // Error should be set
- expect(result.newContextTokens).toBeUndefined()
- expect(mockApiHandler.createMessage).not.toHaveBeenCalled()
- })
-
- it("should return error when both condensing and main API handlers are invalid", async () => {
+ it("should return error when API handler is invalid", async () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
{ role: "assistant", content: "Hi there", ts: 2 },
@@ -1066,14 +1043,8 @@ describe("summarizeConversation", () => {
{ role: "user", content: "Tell me more", ts: 7 },
]
- // Create invalid handlers (missing createMessage)
- const invalidMainHandler = {
- countTokens: vi.fn(),
- getModel: vi.fn(),
- // createMessage is missing
- } as unknown as ApiHandler
-
- const invalidCondensingHandler = {
+ // Create invalid handler (missing createMessage)
+ const invalidHandler = {
countTokens: vi.fn(),
getModel: vi.fn(),
// createMessage is missing
@@ -1084,18 +1055,14 @@ describe("summarizeConversation", () => {
const mockError = vi.fn()
console.error = mockError
- const result = await summarizeConversation(
+ const result = await summarizeConversation({
messages,
- invalidMainHandler,
- defaultSystemPrompt,
+ apiHandler: invalidHandler,
+ systemPrompt: defaultSystemPrompt,
taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false,
- undefined,
- invalidCondensingHandler,
- )
+ })
- // Should return original messages when both handlers are invalid
+ // Should return original messages when handler is invalid
expect(result.messages).toEqual(messages)
expect(result.cost).toBe(0)
expect(result.summary).toBe("")
@@ -1103,395 +1070,66 @@ describe("summarizeConversation", () => {
expect(result.newContextTokens).toBeUndefined()
// Verify error was logged
- expect(mockError).toHaveBeenCalledWith(
- expect.stringContaining("Main API handler is also invalid for condensing"),
- )
+ expect(mockError).toHaveBeenCalledWith(expect.stringContaining("API handler is invalid for condensing"))
// Restore console.error
console.error = originalError
})
- it("should append tool_use blocks to summary message when first kept message has tool_result blocks", async () => {
- const toolUseBlock = {
- type: "tool_use" as const,
- id: "toolu_123",
- name: "read_file",
- input: { path: "test.txt" },
- }
- const toolResultBlock = {
- type: "tool_result" as const,
- tool_use_id: "toolu_123",
- content: "file contents",
- }
-
+ it("should tag all messages with condenseParent (fresh start model)", async () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Let me read that file", ts: 2 },
- { role: "user", content: "Please continue", ts: 3 },
- {
- role: "assistant",
- content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock],
- ts: 4,
- },
- {
- role: "user",
- content: [toolResultBlock, { type: "text" as const, text: "Continue" }],
- ts: 5,
- },
- { role: "assistant", content: "Got it, the file says...", ts: 6 },
- { role: "user", content: "Thanks", ts: 7 },
+ { role: "assistant", content: "Hi there", ts: 2 },
+ { role: "user", content: "How are you?", ts: 3 },
+ { role: "assistant", content: "I'm good", ts: 4 },
+ { role: "user", content: "Thanks", ts: 5 },
]
- // Create a stream with usage information
- const streamWithUsage = (async function* () {
- yield { type: "text" as const, text: "Summary of conversation" }
- yield { type: "usage" as const, totalCost: 0.05, outputTokens: 100 }
- })()
-
- mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any
- mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(50)) as any
-
- const result = await summarizeConversation(
+ const result = await summarizeConversation({
messages,
- mockApiHandler,
- defaultSystemPrompt,
+ apiHandler: mockApiHandler,
+ systemPrompt: defaultSystemPrompt,
taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false, // isAutomaticTrigger
- undefined, // customCondensingPrompt
- undefined, // condensingApiHandler
- true, // useNativeTools - required for tool_use block preservation
- )
-
- // Find the summary message
- const summaryMessage = result.messages.find((m) => m.isSummary)
- expect(summaryMessage).toBeDefined()
- expect(summaryMessage!.role).toBe("assistant")
- expect(summaryMessage!.isSummary).toBe(true)
- expect(Array.isArray(summaryMessage!.content)).toBe(true)
-
- // Content should be [synthetic reasoning, text block, tool_use block]
- // The synthetic reasoning is always added for DeepSeek-reasoner compatibility
- const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[]
- expect(content).toHaveLength(3)
- expect((content[0] as any).type).toBe("reasoning") // Synthetic reasoning for DeepSeek
- expect(content[1].type).toBe("text")
- expect((content[1] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation")
- expect(content[2].type).toBe("tool_use")
- expect((content[2] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_123")
- expect((content[2] as Anthropic.Messages.ToolUseBlockParam).name).toBe("read_file")
-
- // With non-destructive condensing, all messages are retained plus the summary
- expect(result.messages.length).toBe(messages.length + 1) // all original + summary
- // Verify effective history matches expected
- const effectiveHistory = getEffectiveApiHistory(result.messages)
- expect(effectiveHistory.length).toBe(1 + 1 + N_MESSAGES_TO_KEEP) // first + summary + last 3
- expect(result.error).toBeUndefined()
- })
-
- it("should include user tool_result message in summarize request when preserving tool_use blocks", async () => {
- const toolUseBlock = {
- type: "tool_use" as const,
- id: "toolu_history_fix",
- name: "read_file",
- input: { path: "sample.txt" },
- }
- const toolResultBlock = {
- type: "tool_result" as const,
- tool_use_id: "toolu_history_fix",
- content: "file contents",
- }
-
- const messages: ApiMessage[] = [
- { role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Let me help", ts: 2 },
- {
- role: "assistant",
- content: [{ type: "text" as const, text: "Running tool..." }, toolUseBlock],
- ts: 3,
- },
- {
- role: "user",
- content: [toolResultBlock, { type: "text" as const, text: "Thanks" }],
- ts: 4,
- },
- { role: "assistant", content: "Anything else?", ts: 5 },
- { role: "user", content: "Nope", ts: 6 },
- ]
-
- let capturedRequestMessages: any[] | undefined
- const customStream = (async function* () {
- yield { type: "text" as const, text: "Summary of conversation" }
- yield { type: "usage" as const, totalCost: 0.05, outputTokens: 100 }
- })()
-
- mockApiHandler.createMessage = vi.fn().mockImplementation((_prompt, requestMessagesParam) => {
- capturedRequestMessages = requestMessagesParam
- return customStream
- }) as any
-
- const result = await summarizeConversation(
- messages,
- mockApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false,
- undefined,
- undefined,
- true,
- )
-
- expect(result.error).toBeUndefined()
- expect(capturedRequestMessages).toBeDefined()
-
- const requestMessages = capturedRequestMessages!
- expect(requestMessages[requestMessages.length - 1]).toEqual({
- role: "user",
- content: "Summarize the conversation so far, as described in the prompt instructions.",
})
- const historyMessages = requestMessages.slice(0, -1)
- expect(historyMessages.length).toBeGreaterThanOrEqual(2)
-
- const assistantMessage = historyMessages[historyMessages.length - 2]
- const userMessage = historyMessages[historyMessages.length - 1]
-
- expect(assistantMessage.role).toBe("assistant")
- expect(Array.isArray(assistantMessage.content)).toBe(true)
- expect(
- (assistantMessage.content as any[]).some(
- (block) => block.type === "tool_use" && block.id === toolUseBlock.id,
- ),
- ).toBe(true)
-
- expect(userMessage.role).toBe("user")
- expect(Array.isArray(userMessage.content)).toBe(true)
- expect(
- (userMessage.content as any[]).some(
- (block) => block.type === "tool_result" && block.tool_use_id === toolUseBlock.id,
- ),
- ).toBe(true)
- })
-
- it("should append multiple tool_use blocks for parallel tool calls", async () => {
- const toolUseBlockA = {
- type: "tool_use" as const,
- id: "toolu_parallel_1",
- name: "search",
- input: { query: "foo" },
- }
- const toolUseBlockB = {
- type: "tool_use" as const,
- id: "toolu_parallel_2",
- name: "search",
- input: { query: "bar" },
- }
-
- const messages: ApiMessage[] = [
- { role: "user", content: "Start", ts: 1 },
- { role: "assistant", content: "Working...", ts: 2 },
- {
- role: "assistant",
- content: [{ type: "text" as const, text: "Launching parallel tools" }, toolUseBlockA, toolUseBlockB],
- ts: 3,
- },
- {
- role: "user",
- content: [
- { type: "tool_result" as const, tool_use_id: "toolu_parallel_1", content: "result A" },
- { type: "tool_result" as const, tool_use_id: "toolu_parallel_2", content: "result B" },
- { type: "text" as const, text: "Continue" },
- ],
- ts: 4,
- },
- { role: "assistant", content: "Processing results", ts: 5 },
- { role: "user", content: "Thanks", ts: 6 },
- ]
-
- const result = await summarizeConversation(
- messages,
- mockApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false,
- undefined,
- undefined,
- true,
- )
-
- // Find the summary message (it has isSummary: true)
const summaryMessage = result.messages.find((m) => m.isSummary)
expect(summaryMessage).toBeDefined()
- expect(Array.isArray(summaryMessage!.content)).toBe(true)
- const summaryContent = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[]
- // First block is synthetic reasoning for DeepSeek-reasoner compatibility
- expect((summaryContent[0] as any).type).toBe("reasoning")
- // Second block is the text summary
- expect(summaryContent[1]).toEqual({ type: "text", text: "This is a summary" })
+ const condenseId = summaryMessage!.condenseId
- const preservedToolUses = summaryContent.filter(
- (block): block is Anthropic.Messages.ToolUseBlockParam => block.type === "tool_use",
- )
- expect(preservedToolUses).toHaveLength(2)
- expect(preservedToolUses.map((block) => block.id)).toEqual(["toolu_parallel_1", "toolu_parallel_2"])
+ // ALL original messages should be tagged (fresh start model tags everything)
+ for (const msg of result.messages.filter((m) => !m.isSummary)) {
+ expect(msg.condenseParent).toBe(condenseId)
+ }
})
- it("should preserve reasoning blocks in summary message for DeepSeek/Z.ai interleaved thinking", async () => {
- const reasoningBlock = {
- type: "reasoning" as const,
- text: "Let me think about this step by step...",
- }
- const toolUseBlock = {
- type: "tool_use" as const,
- id: "toolu_deepseek_reason",
- name: "read_file",
- input: { path: "test.txt" },
- }
- const toolResultBlock = {
- type: "tool_result" as const,
- tool_use_id: "toolu_deepseek_reason",
- content: "file contents",
- }
-
+ it("should place summary message at end of messages array", async () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
- { role: "assistant", content: "Let me help", ts: 2 },
- { role: "user", content: "Please read the file", ts: 3 },
- {
- role: "assistant",
- // DeepSeek stores reasoning as content blocks alongside tool_use
- content: [reasoningBlock as any, { type: "text" as const, text: "Reading file..." }, toolUseBlock],
- ts: 4,
- },
- {
- role: "user",
- content: [toolResultBlock, { type: "text" as const, text: "Continue" }],
- ts: 5,
- },
- { role: "assistant", content: "Got it, the file says...", ts: 6 },
- { role: "user", content: "Thanks", ts: 7 },
+ { role: "assistant", content: "Hi there", ts: 2 },
+ { role: "user", content: "How are you?", ts: 3 },
+ { role: "assistant", content: "I'm good", ts: 4 },
+ { role: "user", content: "Thanks", ts: 5 },
]
- // Create a stream with usage information
- const streamWithUsage = (async function* () {
- yield { type: "text" as const, text: "Summary of conversation" }
- yield { type: "usage" as const, totalCost: 0.05, outputTokens: 100 }
- })()
-
- mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any
- mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(50)) as any
-
- const result = await summarizeConversation(
+ const result = await summarizeConversation({
messages,
- mockApiHandler,
- defaultSystemPrompt,
+ apiHandler: mockApiHandler,
+ systemPrompt: defaultSystemPrompt,
taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false, // isAutomaticTrigger
- undefined, // customCondensingPrompt
- undefined, // condensingApiHandler
- true, // useNativeTools - required for tool_use block preservation
- )
+ })
- // Find the summary message
- const summaryMessage = result.messages.find((m) => m.isSummary)
- expect(summaryMessage).toBeDefined()
- expect(summaryMessage!.role).toBe("assistant")
- expect(summaryMessage!.isSummary).toBe(true)
- expect(Array.isArray(summaryMessage!.content)).toBe(true)
-
- // Content should be [synthetic reasoning, preserved reasoning, text block, tool_use block]
- // - Synthetic reasoning is always added for DeepSeek-reasoner compatibility
- // - Preserved reasoning from the condensed assistant message
- // This order ensures reasoning_content is always present for DeepSeek/Z.ai
- const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[]
- expect(content).toHaveLength(4)
-
- // First block should be synthetic reasoning
- expect((content[0] as any).type).toBe("reasoning")
- expect((content[0] as any).text).toContain("Condensing conversation context")
-
- // Second block should be preserved reasoning from the condensed message
- expect((content[1] as any).type).toBe("reasoning")
- expect((content[1] as any).text).toBe("Let me think about this step by step...")
-
- // Third block should be text (the summary)
- expect(content[2].type).toBe("text")
- expect((content[2] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation")
-
- // Fourth block should be tool_use
- expect(content[3].type).toBe("tool_use")
- expect((content[3] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_deepseek_reason")
-
- expect(result.error).toBeUndefined()
- })
-
- it("should include synthetic reasoning block in summary for DeepSeek-reasoner compatibility even without tool_use blocks", async () => {
- // This test verifies the fix for the DeepSeek-reasoner 400 error:
- // "Missing `reasoning_content` field in the assistant message at message index 1"
- // DeepSeek-reasoner requires reasoning_content on ALL assistant messages, not just those with tool_calls.
- // After condensation, the summary becomes an assistant message that needs reasoning_content.
- const messages: ApiMessage[] = [
- { role: "user", content: "Tell me a joke", ts: 1 },
- { role: "assistant", content: "Why did the programmer quit?", ts: 2 },
- { role: "user", content: "I don't know, why?", ts: 3 },
- { role: "assistant", content: "He didn't get arrays!", ts: 4 },
- { role: "user", content: "Another one please", ts: 5 },
- { role: "assistant", content: "Why do programmers prefer dark mode?", ts: 6 },
- { role: "user", content: "Why?", ts: 7 },
- ]
-
- // Create a stream with usage information (no tool calls in this conversation)
- const streamWithUsage = (async function* () {
- yield { type: "text" as const, text: "Summary: User requested jokes." }
- yield { type: "usage" as const, totalCost: 0.05, outputTokens: 100 }
- })()
-
- mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any
- mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(50)) as any
-
- const result = await summarizeConversation(
- messages,
- mockApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false, // isAutomaticTrigger
- undefined, // customCondensingPrompt
- undefined, // condensingApiHandler
- false, // useNativeTools - not using tools in this test
- )
-
- // Find the summary message
- const summaryMessage = result.messages.find((m) => m.isSummary)
- expect(summaryMessage).toBeDefined()
- expect(summaryMessage!.role).toBe("assistant")
- expect(summaryMessage!.isSummary).toBe(true)
-
- // CRITICAL: Content must be an array with a synthetic reasoning block
- // This is required for DeepSeek-reasoner which needs reasoning_content on all assistant messages
- expect(Array.isArray(summaryMessage!.content)).toBe(true)
- const content = summaryMessage!.content as any[]
-
- // Should have [synthetic reasoning, text]
- expect(content).toHaveLength(2)
- expect(content[0].type).toBe("reasoning")
- expect(content[0].text).toContain("Condensing conversation context")
- expect(content[1].type).toBe("text")
- expect(content[1].text).toBe("Summary: User requested jokes.")
-
- expect(result.error).toBeUndefined()
+ // Summary should be the last message
+ const lastMessage = result.messages[result.messages.length - 1]
+ expect(lastMessage.isSummary).toBe(true)
+ expect(lastMessage.role).toBe("user")
})
})
describe("summarizeConversation with custom settings", () => {
// Mock necessary dependencies
let mockMainApiHandler: ApiHandler
- let mockCondensingApiHandler: ApiHandler
const defaultSystemPrompt = "Default prompt"
- const taskId = "test-task"
+ const localTaskId = "test-task"
// Sample messages for testing
const sampleMessages: ApiMessage[] = [
@@ -1511,7 +1149,7 @@ describe("summarizeConversation with custom settings", () => {
// Reset telemetry mock
;(TelemetryService.instance.captureContextCondensed as Mock).mockClear()
- // Setup mock API handlers
+ // Setup mock API handler
mockMainApiHandler = {
createMessage: vi.fn().mockImplementation(() => {
return (async function* () {
@@ -1534,29 +1172,6 @@ describe("summarizeConversation with custom settings", () => {
},
}),
} as unknown as ApiHandler
-
- mockCondensingApiHandler = {
- createMessage: vi.fn().mockImplementation(() => {
- return (async function* () {
- yield { type: "text" as const, text: "Summary from condensing handler" }
- yield { type: "usage" as const, totalCost: 0.03, outputTokens: 80 }
- })()
- }),
- countTokens: vi.fn().mockImplementation(() => Promise.resolve(40)),
- getModel: vi.fn().mockReturnValue({
- id: "condensing-model",
- info: {
- contextWindow: 4000,
- supportsImages: true,
- supportsVision: false,
- maxTokens: 2000,
- supportsPromptCache: false,
- maxCachePoints: 0,
- minTokensPerCachePoint: 0,
- cachableFields: [],
- },
- }),
- } as unknown as ApiHandler
})
/**
@@ -1565,20 +1180,23 @@ describe("summarizeConversation with custom settings", () => {
it("should use custom prompt when provided", async () => {
const customPrompt = "Custom summarization prompt"
- await summarizeConversation(
- sampleMessages,
- mockMainApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false,
- customPrompt,
- )
+ await summarizeConversation({
+ messages: sampleMessages,
+ apiHandler: mockMainApiHandler,
+ systemPrompt: defaultSystemPrompt,
+ taskId: localTaskId,
+ isAutomaticTrigger: false,
+ customCondensingPrompt: customPrompt,
+ })
- // Verify the custom prompt was used
+ // Verify the custom prompt was used in the user message content
const createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls
expect(createMessageCalls.length).toBe(1)
- expect(createMessageCalls[0][0]).toBe(customPrompt)
+ // The custom prompt should be in the last message (the finalRequestMessage)
+ const requestMessages = createMessageCalls[0][1]
+ const lastMessage = requestMessages[requestMessages.length - 1]
+ expect(lastMessage.role).toBe("user")
+ expect(lastMessage.content).toBe(customPrompt)
})
/**
@@ -1586,185 +1204,81 @@ describe("summarizeConversation with custom settings", () => {
*/
it("should use default systemPrompt when custom prompt is empty or not provided", async () => {
// Test with empty string
- await summarizeConversation(
- sampleMessages,
- mockMainApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false,
- " ", // Empty custom prompt
- )
+ await summarizeConversation({
+ messages: sampleMessages,
+ apiHandler: mockMainApiHandler,
+ systemPrompt: defaultSystemPrompt,
+ taskId: localTaskId,
+ isAutomaticTrigger: false,
+ customCondensingPrompt: " ",
+ })
- // Verify the default prompt was used
+ // Verify the default SUMMARY_PROMPT was used (contains CRITICAL instructions)
let createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls
expect(createMessageCalls.length).toBe(1)
- expect(createMessageCalls[0][0]).toContain("Your task is to create a detailed summary")
+ expect(createMessageCalls[0][0]).toContain(
+ "You are a helpful AI assistant tasked with summarizing conversations.",
+ )
+ expect(createMessageCalls[0][0]).toContain("CRITICAL: This is a summarization-only request")
// Reset mock and test with undefined
vi.clearAllMocks()
- await summarizeConversation(
- sampleMessages,
- mockMainApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false,
- undefined, // No custom prompt
- )
+ await summarizeConversation({
+ messages: sampleMessages,
+ apiHandler: mockMainApiHandler,
+ systemPrompt: defaultSystemPrompt,
+ taskId: localTaskId,
+ isAutomaticTrigger: false,
+ })
- // Verify the default prompt was used again
+ // Verify the default SUMMARY_PROMPT was used again (contains CRITICAL instructions)
createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls
expect(createMessageCalls.length).toBe(1)
- expect(createMessageCalls[0][0]).toContain("Your task is to create a detailed summary")
- })
-
- /**
- * Test that condensing API handler is used when provided and valid
- */
- it("should use condensingApiHandler when provided and valid", async () => {
- await summarizeConversation(
- sampleMessages,
- mockMainApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false,
- undefined,
- mockCondensingApiHandler,
+ expect(createMessageCalls[0][0]).toContain(
+ "You are a helpful AI assistant tasked with summarizing conversations.",
)
-
- // Verify the condensing handler was used
- expect((mockCondensingApiHandler.createMessage as Mock).mock.calls.length).toBe(1)
- expect((mockMainApiHandler.createMessage as Mock).mock.calls.length).toBe(0)
- })
-
- /**
- * Test fallback to main API handler when condensing handler is not provided
- */
- it("should fall back to mainApiHandler if condensingApiHandler is not provided", async () => {
- await summarizeConversation(
- sampleMessages,
- mockMainApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false,
- undefined,
- undefined,
- )
-
- // Verify the main handler was used
- expect((mockMainApiHandler.createMessage as Mock).mock.calls.length).toBe(1)
- })
-
- /**
- * Test fallback to main API handler when condensing handler is invalid
- */
- it("should fall back to mainApiHandler if condensingApiHandler is invalid", async () => {
- // Create an invalid handler (missing createMessage)
- const invalidHandler = {
- countTokens: vi.fn(),
- getModel: vi.fn(),
- // createMessage is missing
- } as unknown as ApiHandler
-
- // Mock console.warn to verify warning message
- const originalWarn = console.warn
- const mockWarn = vi.fn()
- console.warn = mockWarn
-
- await summarizeConversation(
- sampleMessages,
- mockMainApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false,
- undefined,
- invalidHandler,
- )
-
- // Verify the main handler was used as fallback
- expect((mockMainApiHandler.createMessage as Mock).mock.calls.length).toBe(1)
-
- // Verify warning was logged
- expect(mockWarn).toHaveBeenCalledWith(
- expect.stringContaining("Chosen API handler for condensing does not support message creation"),
- )
-
- // Restore console.warn
- console.warn = originalWarn
+ expect(createMessageCalls[0][0]).toContain("CRITICAL: This is a summarization-only request")
})
/**
* Test that telemetry is called for custom prompt usage
*/
it("should capture telemetry when using custom prompt", async () => {
- await summarizeConversation(
- sampleMessages,
- mockMainApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false,
- "Custom prompt",
- )
+ await summarizeConversation({
+ messages: sampleMessages,
+ apiHandler: mockMainApiHandler,
+ systemPrompt: defaultSystemPrompt,
+ taskId: localTaskId,
+ isAutomaticTrigger: false,
+ customCondensingPrompt: "Custom prompt",
+ })
// Verify telemetry was called with custom prompt flag
expect(TelemetryService.instance.captureContextCondensed).toHaveBeenCalledWith(
- taskId,
+ localTaskId,
false,
true, // usedCustomPrompt
- false, // usedCustomApiHandler
)
})
/**
- * Test that telemetry is called for custom API handler usage
+ * Test that telemetry is called with isAutomaticTrigger flag
*/
- it("should capture telemetry when using custom API handler", async () => {
- await summarizeConversation(
- sampleMessages,
- mockMainApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- false,
- undefined,
- mockCondensingApiHandler,
- )
+ it("should capture telemetry with isAutomaticTrigger flag", async () => {
+ await summarizeConversation({
+ messages: sampleMessages,
+ apiHandler: mockMainApiHandler,
+ systemPrompt: defaultSystemPrompt,
+ taskId: localTaskId,
+ isAutomaticTrigger: true,
+ customCondensingPrompt: "Custom prompt",
+ })
- // Verify telemetry was called with custom API handler flag
+ // Verify telemetry was called with isAutomaticTrigger flag
expect(TelemetryService.instance.captureContextCondensed).toHaveBeenCalledWith(
- taskId,
- false,
- false, // usedCustomPrompt
- true, // usedCustomApiHandler
- )
- })
-
- /**
- * Test that telemetry is called with both custom prompt and API handler
- */
- it("should capture telemetry when using both custom prompt and API handler", async () => {
- await summarizeConversation(
- sampleMessages,
- mockMainApiHandler,
- defaultSystemPrompt,
- taskId,
- DEFAULT_PREV_CONTEXT_TOKENS,
- true, // isAutomaticTrigger
- "Custom prompt",
- mockCondensingApiHandler,
- )
-
- // Verify telemetry was called with both flags
- expect(TelemetryService.instance.captureContextCondensed).toHaveBeenCalledWith(
- taskId,
+ localTaskId,
true, // isAutomaticTrigger
true, // usedCustomPrompt
- true, // usedCustomApiHandler
)
})
})
diff --git a/src/core/condense/__tests__/nested-condense.spec.ts b/src/core/condense/__tests__/nested-condense.spec.ts
new file mode 100644
index 0000000000..3868a22262
--- /dev/null
+++ b/src/core/condense/__tests__/nested-condense.spec.ts
@@ -0,0 +1,211 @@
+import { describe, it, expect } from "vitest"
+import { ApiMessage } from "../../task-persistence/apiMessages"
+import { getEffectiveApiHistory, getMessagesSinceLastSummary } from "../index"
+
+describe("nested condensing scenarios", () => {
+ describe("fresh-start model (user-role summaries)", () => {
+ it("should return only the latest summary and messages after it", () => {
+ const condenseId1 = "condense-1"
+ const condenseId2 = "condense-2"
+
+ // Simulate history after two nested condenses with user-role summaries
+ const history: ApiMessage[] = [
+ // Original task - condensed in first condense
+ { role: "user", content: "Build an app", ts: 100, condenseParent: condenseId1 },
+ // Messages from first condense
+ { role: "assistant", content: "Starting...", ts: 200, condenseParent: condenseId1 },
+ { role: "user", content: "Add auth", ts: 300, condenseParent: condenseId1 },
+ // First summary (user role, fresh-start model) - then condensed in second condense
+ {
+ role: "user",
+ content: [{ type: "text", text: "## Summary 1" }],
+ ts: 399,
+ isSummary: true,
+ condenseId: condenseId1,
+ condenseParent: condenseId2, // Tagged during second condense
+ },
+ // Messages after first condense but before second
+ { role: "assistant", content: "Auth added", ts: 400, condenseParent: condenseId2 },
+ { role: "user", content: "Add database", ts: 500, condenseParent: condenseId2 },
+ // Second summary (user role, fresh-start model)
+ {
+ role: "user",
+ content: [{ type: "text", text: "## Summary 2" }],
+ ts: 599,
+ isSummary: true,
+ condenseId: condenseId2,
+ },
+ // Messages after second condense (kept messages)
+ { role: "assistant", content: "Database added", ts: 600 },
+ { role: "user", content: "Now test it", ts: 700 },
+ ]
+
+ // Step 1: Get effective history
+ const effectiveHistory = getEffectiveApiHistory(history)
+
+ // Should only contain: Summary2, and messages after it
+ expect(effectiveHistory.length).toBe(3)
+ expect(effectiveHistory[0].isSummary).toBe(true)
+ expect(effectiveHistory[0].condenseId).toBe(condenseId2) // Latest summary
+ expect(effectiveHistory[1].content).toBe("Database added")
+ expect(effectiveHistory[2].content).toBe("Now test it")
+
+ // Verify NO condensed messages are included
+ const hasCondensedMessages = effectiveHistory.some(
+ (msg) => msg.condenseParent && history.some((m) => m.isSummary && m.condenseId === msg.condenseParent),
+ )
+ expect(hasCondensedMessages).toBe(false)
+
+ // Step 2: Get messages since last summary (on effective history)
+ const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory)
+
+ // Should be the same as effective history since Summary2 is already at the start
+ expect(messagesSinceLastSummary.length).toBe(3)
+ expect(messagesSinceLastSummary[0].isSummary).toBe(true)
+ expect(messagesSinceLastSummary[0].condenseId).toBe(condenseId2)
+
+ // CRITICAL: No previous history (Summary1 or original task) should be included
+ const hasSummary1 = messagesSinceLastSummary.some((m) => m.condenseId === condenseId1)
+ expect(hasSummary1).toBe(false)
+
+ const hasOriginalTask = messagesSinceLastSummary.some((m) => m.content === "Build an app")
+ expect(hasOriginalTask).toBe(false)
+ })
+
+ it("should handle triple nested condense correctly", () => {
+ const condenseId1 = "condense-1"
+ const condenseId2 = "condense-2"
+ const condenseId3 = "condense-3"
+
+ const history: ApiMessage[] = [
+ // First condense content
+ { role: "user", content: "Task", ts: 100, condenseParent: condenseId1 },
+ {
+ role: "user",
+ content: [{ type: "text", text: "## Summary 1" }],
+ ts: 199,
+ isSummary: true,
+ condenseId: condenseId1,
+ condenseParent: condenseId2,
+ },
+ // Second condense content
+ { role: "assistant", content: "After S1", ts: 200, condenseParent: condenseId2 },
+ {
+ role: "user",
+ content: [{ type: "text", text: "## Summary 2" }],
+ ts: 299,
+ isSummary: true,
+ condenseId: condenseId2,
+ condenseParent: condenseId3,
+ },
+ // Third condense content
+ { role: "assistant", content: "After S2", ts: 300, condenseParent: condenseId3 },
+ {
+ role: "user",
+ content: [{ type: "text", text: "## Summary 3" }],
+ ts: 399,
+ isSummary: true,
+ condenseId: condenseId3,
+ },
+ // Current messages
+ { role: "assistant", content: "Current work", ts: 400 },
+ ]
+
+ const effectiveHistory = getEffectiveApiHistory(history)
+
+ // Should only contain Summary3 and current work
+ expect(effectiveHistory.length).toBe(2)
+ expect(effectiveHistory[0].condenseId).toBe(condenseId3)
+ expect(effectiveHistory[1].content).toBe("Current work")
+
+ const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory)
+ expect(messagesSinceLastSummary.length).toBe(2)
+
+ // No previous summaries should be included
+ const hasPreviousSummaries = messagesSinceLastSummary.some(
+ (m) => m.condenseId === condenseId1 || m.condenseId === condenseId2,
+ )
+ expect(hasPreviousSummaries).toBe(false)
+ })
+ })
+
+ describe("getMessagesSinceLastSummary behavior with full vs effective history", () => {
+ it("should return consistent results when called with full history vs effective history", () => {
+ const condenseId = "condense-1"
+
+ const fullHistory: ApiMessage[] = [
+ { role: "user", content: "Original task", ts: 100, condenseParent: condenseId },
+ { role: "assistant", content: "Response", ts: 200, condenseParent: condenseId },
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary" }],
+ ts: 299,
+ isSummary: true,
+ condenseId,
+ },
+ { role: "assistant", content: "After summary", ts: 300 },
+ ]
+
+ // Called with FULL history (as in summarizeConversation)
+ const fromFullHistory = getMessagesSinceLastSummary(fullHistory)
+
+ // Called with EFFECTIVE history (as in attemptApiRequest)
+ const effectiveHistory = getEffectiveApiHistory(fullHistory)
+ const fromEffectiveHistory = getMessagesSinceLastSummary(effectiveHistory)
+
+ // Both should return the same messages when summary is user role
+ expect(fromFullHistory.length).toBe(fromEffectiveHistory.length)
+
+ // Both should start with the summary
+ expect(fromFullHistory[0].isSummary).toBe(true)
+ expect(fromEffectiveHistory[0].isSummary).toBe(true)
+ })
+
+ it("should not include condensed original task in effective history", () => {
+ const condenseId1 = "condense-1"
+ const condenseId2 = "condense-2"
+
+ // Scenario: Two nested condenses with user-role summaries
+ const fullHistory: ApiMessage[] = [
+ { role: "user", content: "Original task - should NOT appear", ts: 100, condenseParent: condenseId1 },
+ { role: "assistant", content: "Old response", ts: 200, condenseParent: condenseId1 },
+ // First summary (user role, fresh-start model), then condensed again
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary 1" }],
+ ts: 299,
+ isSummary: true,
+ condenseId: condenseId1,
+ condenseParent: condenseId2,
+ },
+ { role: "assistant", content: "After S1", ts: 300, condenseParent: condenseId2 },
+ // Second summary (user role, fresh-start model)
+ {
+ role: "user",
+ content: [{ type: "text", text: "Summary 2" }],
+ ts: 399,
+ isSummary: true,
+ condenseId: condenseId2,
+ },
+ { role: "assistant", content: "Current message", ts: 400 },
+ ]
+
+ const effectiveHistory = getEffectiveApiHistory(fullHistory)
+ expect(effectiveHistory.length).toBe(2) // Summary2 + Current message
+
+ const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory)
+
+ // The original task should NOT be included
+ const hasOriginalTask = messagesSinceLastSummary.some((m) =>
+ typeof m.content === "string"
+ ? m.content.includes("Original task")
+ : JSON.stringify(m.content).includes("Original task"),
+ )
+ expect(hasOriginalTask).toBe(false)
+
+ // Summary1 should not be included (it was condensed)
+ const hasSummary1 = messagesSinceLastSummary.some((m) => m.condenseId === condenseId1)
+ expect(hasSummary1).toBe(false)
+ })
+ })
+})
diff --git a/src/core/condense/__tests__/rewind-after-condense.spec.ts b/src/core/condense/__tests__/rewind-after-condense.spec.ts
index f5f1a09380..068f49a857 100644
--- a/src/core/condense/__tests__/rewind-after-condense.spec.ts
+++ b/src/core/condense/__tests__/rewind-after-condense.spec.ts
@@ -22,25 +22,25 @@ describe("Rewind After Condense - Issue #8295", () => {
})
describe("getEffectiveApiHistory", () => {
- it("should filter out messages tagged with condenseParent", () => {
+ it("should return summary and messages after summary (fresh start model)", () => {
const condenseId = "summary-123"
const messages: ApiMessage[] = [
- { role: "user", content: "First message", ts: 1 },
+ { role: "user", content: "First message", ts: 1, condenseParent: condenseId },
{ role: "assistant", content: "First response", ts: 2, condenseParent: condenseId },
{ role: "user", content: "Second message", ts: 3, condenseParent: condenseId },
- { role: "assistant", content: "Summary", ts: 4, isSummary: true, condenseId },
- { role: "user", content: "Third message", ts: 5 },
- { role: "assistant", content: "Third response", ts: 6 },
+ { role: "user", content: "Summary", ts: 4, isSummary: true, condenseId },
+ // Messages after summary are included even if they have condenseParent
+ { role: "user", content: "Third message", ts: 5, condenseParent: condenseId },
+ { role: "assistant", content: "Third response", ts: 6, condenseParent: condenseId },
]
const effective = getEffectiveApiHistory(messages)
- // Effective history should be: first message, summary, third message, third response
- expect(effective.length).toBe(4)
- expect(effective[0].content).toBe("First message")
- expect(effective[1].isSummary).toBe(true)
- expect(effective[2].content).toBe("Third message")
- expect(effective[3].content).toBe("Third response")
+ // Fresh start model: summary + all messages after it
+ expect(effective.length).toBe(3)
+ expect(effective[0].isSummary).toBe(true)
+ expect(effective[1].content).toBe("Third message")
+ expect(effective[2].content).toBe("Third response")
})
it("should include messages without condenseParent", () => {
@@ -83,7 +83,7 @@ describe("Rewind After Condense - Issue #8295", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "First message", ts: 1 },
{ role: "assistant", content: "First response", ts: 2, condenseParent: condenseId },
- { role: "assistant", content: "Summary", ts: 3, isSummary: true, condenseId },
+ { role: "user", content: "Summary", ts: 3, isSummary: true, condenseId },
]
const cleaned = cleanupAfterTruncation(messages)
@@ -97,7 +97,7 @@ describe("Rewind After Condense - Issue #8295", () => {
const condenseId2 = "summary-2"
const messages: ApiMessage[] = [
{ role: "user", content: "Message 1", ts: 1, condenseParent: condenseId1 },
- { role: "assistant", content: "Summary 1", ts: 2, isSummary: true, condenseId: condenseId1 },
+ { role: "user", content: "Summary 1", ts: 2, isSummary: true, condenseId: condenseId1 },
{ role: "user", content: "Message 2", ts: 3, condenseParent: condenseId2 },
// Summary 2 is NOT present (was truncated)
]
@@ -131,44 +131,32 @@ describe("Rewind After Condense - Issue #8295", () => {
it("should reactivate condensed messages when their summary is deleted via truncation", () => {
const condenseId = "summary-abc"
- // Simulate a conversation after condensing
+ // Simulate a conversation after condensing (all prior messages tagged)
const fullHistory: ApiMessage[] = [
- { role: "user", content: "Initial task", ts: 1 },
+ { role: "user", content: "Initial task", ts: 1, condenseParent: condenseId },
{ role: "assistant", content: "Working on it", ts: 2, condenseParent: condenseId },
{ role: "user", content: "Continue", ts: 3, condenseParent: condenseId },
- { role: "assistant", content: "Summary of work so far", ts: 4, isSummary: true, condenseId },
- { role: "user", content: "Now do this", ts: 5 },
- { role: "assistant", content: "Done", ts: 6 },
- { role: "user", content: "And this", ts: 7 },
- { role: "assistant", content: "Also done", ts: 8 },
+ { role: "user", content: "Summary of work so far", ts: 4, isSummary: true, condenseId },
]
// Verify effective history before truncation
const effectiveBefore = getEffectiveApiHistory(fullHistory)
- // Should be: first message, summary, last 4 messages
- expect(effectiveBefore.length).toBe(6)
+ // Should be: summary only
+ expect(effectiveBefore.length).toBe(1)
- // Simulate rewind: user truncates back to message ts=4 (keeping 0-3)
- const truncatedHistory = fullHistory.slice(0, 4) // Keep first, condensed1, condensed2, summary
+ // Simulate rewind: delete the summary message
+ const withoutSummary = fullHistory.filter((m) => !m.isSummary)
+ const cleanedAfterDeletingSummary = cleanupAfterTruncation(withoutSummary)
+ for (const msg of cleanedAfterDeletingSummary) {
+ expect(msg.condenseParent).toBeUndefined()
+ }
- // After truncation, the summary is still there, so condensed messages remain condensed
- const cleanedAfterKeepingSummary = cleanupAfterTruncation(truncatedHistory)
- expect(cleanedAfterKeepingSummary[1].condenseParent).toBe(condenseId)
- expect(cleanedAfterKeepingSummary[2].condenseParent).toBe(condenseId)
-
- // Now simulate a more aggressive rewind: delete back to message ts=2
- const aggressiveTruncate = fullHistory.slice(0, 2) // Keep only first message and first response
-
- // The condensed messages should now be reactivated since summary is gone
- const cleanedAfterDeletingSummary = cleanupAfterTruncation(aggressiveTruncate)
- expect(cleanedAfterDeletingSummary[1].condenseParent).toBeUndefined()
-
- // Verify effective history after cleanup
+ // Verify effective history after cleanup: all messages should be visible now
const effectiveAfterCleanup = getEffectiveApiHistory(cleanedAfterDeletingSummary)
- // Now both messages should be active (no condensed filtering)
- expect(effectiveAfterCleanup.length).toBe(2)
+ expect(effectiveAfterCleanup.length).toBe(3)
expect(effectiveAfterCleanup[0].content).toBe("Initial task")
expect(effectiveAfterCleanup[1].content).toBe("Working on it")
+ expect(effectiveAfterCleanup[2].content).toBe("Continue")
})
it("should properly restore context after rewind when summary was deleted", () => {
@@ -206,24 +194,26 @@ describe("Rewind After Condense - Issue #8295", () => {
expect(effectiveAfter.length).toBe(5) // All messages visible
})
- it("should hide condensed messages when their summary still exists", () => {
+ it("should hide condensed messages when their summary still exists (fresh start)", () => {
const condenseId = "summary-exists"
- // Scenario: Messages were condensed and summary exists - condensed messages should be hidden
+ // Scenario: Messages were condensed and summary exists - fresh start model returns
+ // only the summary and messages after it, NOT messages before the summary
const messages: ApiMessage[] = [
{ role: "user", content: "Start", ts: 1 },
{ role: "assistant", content: "Response 1", ts: 2, condenseParent: condenseId },
{ role: "user", content: "More", ts: 3, condenseParent: condenseId },
- { role: "assistant", content: "Summary", ts: 4, isSummary: true, condenseId },
- { role: "user", content: "After summary", ts: 5 },
+ { role: "user", content: "Summary", ts: 4, isSummary: true, condenseId },
+ { role: "assistant", content: "After summary", ts: 5 },
]
- // Effective history should hide condensed messages since summary exists
+ // Fresh start model: effective history is summary + messages after it
+ // "Start" is NOT included because it's before the summary
const effective = getEffectiveApiHistory(messages)
- expect(effective.length).toBe(3) // Start, Summary, After summary
- expect(effective[0].content).toBe("Start")
- expect(effective[1].content).toBe("Summary")
- expect(effective[2].content).toBe("After summary")
+ expect(effective.length).toBe(2) // Summary, After summary (NOT Start)
+ expect(effective[0].content).toBe("Summary")
+ expect(effective[0].isSummary).toBe(true)
+ expect(effective[1].content).toBe("After summary")
// cleanupAfterTruncation should NOT clear condenseParent since summary exists
const cleaned = cleanupAfterTruncation(messages)
@@ -260,7 +250,7 @@ describe("Rewind After Condense - Issue #8295", () => {
{ role: "assistant", content: "Response 3", ts: 600, condenseParent: condenseId },
{ role: "user", content: "Even more", ts: 700, condenseParent: condenseId },
// Summary gets ts = firstKeptTs - 1 = 999, which is unique
- { role: "assistant", content: "Summary", ts: firstKeptTs - 1, isSummary: true, condenseId },
+ { role: "user", content: "Summary", ts: firstKeptTs - 1, isSummary: true, condenseId },
// First kept message
{ role: "user", content: "First kept message", ts: firstKeptTs },
{ role: "assistant", content: "Response to first kept", ts: 1100 },
@@ -293,9 +283,9 @@ describe("Rewind After Condense - Issue #8295", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Initial", ts: 1 },
- { role: "assistant", content: "Summary", ts: firstKeptTs - 1, isSummary: true, condenseId },
- { role: "user", content: "First kept message", ts: firstKeptTs },
- { role: "assistant", content: "Response", ts: 9 },
+ { role: "user", content: "Summary", ts: firstKeptTs - 1, isSummary: true, condenseId },
+ { role: "assistant", content: "First kept message", ts: firstKeptTs },
+ { role: "user", content: "Response", ts: 9 },
]
// Look up by first kept message's timestamp
@@ -315,8 +305,7 @@ describe("Rewind After Condense - Issue #8295", () => {
/**
* These tests verify that the correct user and assistant messages are preserved
* and sent to the LLM after condense operations. With N_MESSAGES_TO_KEEP = 3,
- * condense should always preserve:
- * - The first message (never condensed)
+ * condense should always preserve (for effective history):
* - The active summary
* - The last 3 kept messages
*/
@@ -332,7 +321,7 @@ describe("Rewind After Condense - Issue #8295", () => {
// - summary inserted with ts = msg8.ts - 1
// - msg8, msg9, msg10 kept
const storageAfterCondense: ApiMessage[] = [
- { role: "user", content: "Task: Build a feature", ts: 100 },
+ { role: "user", content: "Task: Build a feature", ts: 100, condenseParent: condenseId },
{ role: "assistant", content: "I'll help with that", ts: 200, condenseParent: condenseId },
{ role: "user", content: "Start with the API", ts: 300, condenseParent: condenseId },
{ role: "assistant", content: "Creating API endpoints", ts: 400, condenseParent: condenseId },
@@ -341,7 +330,7 @@ describe("Rewind After Condense - Issue #8295", () => {
{ role: "user", content: "Now the tests", ts: 700, condenseParent: condenseId },
// Summary inserted before first kept message
{
- role: "assistant",
+ role: "user",
content: "Summary: Built API with validation, working on tests",
ts: 799, // msg8.ts - 1
isSummary: true,
@@ -355,28 +344,24 @@ describe("Rewind After Condense - Issue #8295", () => {
const effective = getEffectiveApiHistory(storageAfterCondense)
- // Should send exactly 5 messages to LLM:
- // 1. First message (user) - preserved
- // 2. Summary (assistant)
- // 3-5. Last 3 kept messages
- expect(effective.length).toBe(5)
+ // Should send exactly 4 messages to LLM:
+ // 1. Summary (user)
+ // 2-4. Last 3 kept messages
+ expect(effective.length).toBe(4)
// Verify exact order and content
expect(effective[0].role).toBe("user")
- expect(effective[0].content).toBe("Task: Build a feature")
+ expect(effective[0].isSummary).toBe(true)
+ expect(effective[0].content).toBe("Summary: Built API with validation, working on tests")
expect(effective[1].role).toBe("assistant")
- expect(effective[1].isSummary).toBe(true)
- expect(effective[1].content).toBe("Summary: Built API with validation, working on tests")
+ expect(effective[1].content).toBe("Writing unit tests now")
- expect(effective[2].role).toBe("assistant")
- expect(effective[2].content).toBe("Writing unit tests now")
+ expect(effective[2].role).toBe("user")
+ expect(effective[2].content).toBe("Include edge cases")
- expect(effective[3].role).toBe("user")
- expect(effective[3].content).toBe("Include edge cases")
-
- expect(effective[4].role).toBe("assistant")
- expect(effective[4].content).toBe("Added edge case tests")
+ expect(effective[3].role).toBe("assistant")
+ expect(effective[3].content).toBe("Added edge case tests")
// Verify condensed messages are NOT in effective history
const condensedContents = ["I'll help with that", "Start with the API", "Creating API endpoints"]
@@ -396,8 +381,8 @@ describe("Rewind After Condense - Issue #8295", () => {
//
// Storage after double condense:
const storageAfterDoubleCondense: ApiMessage[] = [
- // First message - never condensed
- { role: "user", content: "Initial task: Build a full app", ts: 100 },
+ // First message - condensed during the first condense
+ { role: "user", content: "Initial task: Build a full app", ts: 100, condenseParent: condenseId1 },
// Messages from first condense (tagged with condenseId1)
{ role: "assistant", content: "Starting the project", ts: 200, condenseParent: condenseId1 },
@@ -409,7 +394,7 @@ describe("Rewind After Condense - Issue #8295", () => {
// First summary - now ALSO tagged with condenseId2 (from second condense)
{
- role: "assistant",
+ role: "user",
content: "Summary1: Built auth and database",
ts: 799,
isSummary: true,
@@ -431,7 +416,7 @@ describe("Rewind After Condense - Issue #8295", () => {
// Second summary - inserted before the last 3 kept messages
{
- role: "assistant",
+ role: "user",
content: "Summary2: App complete with auth, DB, API, validation, errors, logging. Now testing.",
ts: 1799, // msg18.ts - 1
isSummary: true,
@@ -446,29 +431,25 @@ describe("Rewind After Condense - Issue #8295", () => {
const effective = getEffectiveApiHistory(storageAfterDoubleCondense)
- // Should send exactly 5 messages to LLM:
- // 1. First message (user) - preserved
- // 2. Summary2 (assistant) - the ACTIVE summary
- // 3-5. Last 3 kept messages
- expect(effective.length).toBe(5)
+ // Should send exactly 4 messages to LLM:
+ // 1. Summary2 (user) - the ACTIVE summary
+ // 2-4. Last 3 kept messages
+ expect(effective.length).toBe(4)
// Verify exact order and content
expect(effective[0].role).toBe("user")
- expect(effective[0].content).toBe("Initial task: Build a full app")
+ expect(effective[0].isSummary).toBe(true)
+ expect(effective[0].condenseId).toBe(condenseId2) // Must be the SECOND summary
+ expect(effective[0].content).toContain("Summary2")
expect(effective[1].role).toBe("assistant")
- expect(effective[1].isSummary).toBe(true)
- expect(effective[1].condenseId).toBe(condenseId2) // Must be the SECOND summary
- expect(effective[1].content).toContain("Summary2")
+ expect(effective[1].content).toBe("Writing integration tests")
- expect(effective[2].role).toBe("assistant")
- expect(effective[2].content).toBe("Writing integration tests")
+ expect(effective[2].role).toBe("user")
+ expect(effective[2].content).toBe("Test the auth flow")
- expect(effective[3].role).toBe("user")
- expect(effective[3].content).toBe("Test the auth flow")
-
- expect(effective[4].role).toBe("assistant")
- expect(effective[4].content).toBe("Auth tests passing")
+ expect(effective[3].role).toBe("assistant")
+ expect(effective[3].content).toBe("Auth tests passing")
// Verify Summary1 is NOT in effective history (it's tagged with condenseParent)
const summary1 = effective.find((m) => m.content?.toString().includes("Summary1"))
@@ -493,10 +474,10 @@ describe("Rewind After Condense - Issue #8295", () => {
// Verify that after condense, the effective history maintains proper
// user/assistant message alternation (important for API compatibility)
const storage: ApiMessage[] = [
- { role: "user", content: "Start task", ts: 100 },
+ { role: "user", content: "Start task", ts: 100, condenseParent: condenseId },
{ role: "assistant", content: "Response 1", ts: 200, condenseParent: condenseId },
{ role: "user", content: "Continue", ts: 300, condenseParent: condenseId },
- { role: "assistant", content: "Summary text", ts: 399, isSummary: true, condenseId },
+ { role: "user", content: "Summary text", ts: 399, isSummary: true, condenseId },
// Kept messages - should alternate properly
{ role: "assistant", content: "Response after summary", ts: 400 },
{ role: "user", content: "User message", ts: 500 },
@@ -505,27 +486,25 @@ describe("Rewind After Condense - Issue #8295", () => {
const effective = getEffectiveApiHistory(storage)
- // Verify the sequence: user, assistant(summary), assistant, user, assistant
- // Note: Having two assistant messages in a row (summary + next response) is valid
- // because the summary replaces what would have been multiple messages
+ // Verify the sequence: user(summary), assistant, user, assistant
+ // This is the fresh-start model with user-role summaries
expect(effective[0].role).toBe("user")
+ expect(effective[0].isSummary).toBe(true)
expect(effective[1].role).toBe("assistant")
- expect(effective[1].isSummary).toBe(true)
- expect(effective[2].role).toBe("assistant")
- expect(effective[3].role).toBe("user")
- expect(effective[4].role).toBe("assistant")
+ expect(effective[2].role).toBe("user")
+ expect(effective[3].role).toBe("assistant")
})
it("should preserve timestamps in chronological order in effective history", () => {
const condenseId = "summary-timestamps"
const storage: ApiMessage[] = [
- { role: "user", content: "First", ts: 100 },
+ { role: "user", content: "First", ts: 100, condenseParent: condenseId },
{ role: "assistant", content: "Condensed", ts: 200, condenseParent: condenseId },
- { role: "assistant", content: "Summary", ts: 299, isSummary: true, condenseId },
- { role: "user", content: "Kept 1", ts: 300 },
- { role: "assistant", content: "Kept 2", ts: 400 },
- { role: "user", content: "Kept 3", ts: 500 },
+ { role: "user", content: "Summary", ts: 299, isSummary: true, condenseId },
+ { role: "assistant", content: "Kept 1", ts: 300 },
+ { role: "user", content: "Kept 2", ts: 400 },
+ { role: "assistant", content: "Kept 3", ts: 500 },
]
const effective = getEffectiveApiHistory(storage)
diff --git a/src/core/condense/foldedFileContext.ts b/src/core/condense/foldedFileContext.ts
new file mode 100644
index 0000000000..360dd7fc76
--- /dev/null
+++ b/src/core/condense/foldedFileContext.ts
@@ -0,0 +1,168 @@
+import * as path from "path"
+import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
+import { RooIgnoreController } from "../ignore/RooIgnoreController"
+
+/**
+ * Checks if a definitions string is actually an error message from tree-sitter
+ * rather than valid code definitions. These error strings should not be embedded
+ * in the folded file context - instead, the file should be skipped.
+ */
+function isTreeSitterErrorString(definitions: string): boolean {
+ // These are known error messages from parseSourceCodeDefinitionsForFile
+ const errorPatterns = ["This file does not exist", "do not have permission", "Unsupported file type:"]
+ return errorPatterns.some((pattern) => definitions.includes(pattern))
+}
+
+/**
+ * Result of generating folded file context.
+ */
+export interface FoldedFileContextResult {
+ /** The formatted string containing all folded file definitions (joined) */
+ content: string
+ /** Individual file sections, each in its own block */
+ sections: string[]
+ /** Number of files successfully processed */
+ filesProcessed: number
+ /** Number of files that failed or were skipped */
+ filesSkipped: number
+ /** Total character count of the folded content */
+ characterCount: number
+}
+
+/**
+ * Options for generating folded file context.
+ */
+export interface FoldedFileContextOptions {
+ /** Maximum total characters for the folded content (default: 50000) */
+ maxCharacters?: number
+ /** The current working directory for resolving relative paths */
+ cwd: string
+ /** Optional RooIgnoreController for file access validation */
+ rooIgnoreController?: RooIgnoreController
+}
+
+/**
+ * Generates folded (signatures-only) file context for a list of files using tree-sitter.
+ *
+ * This function takes file paths that were read during a conversation and produces
+ * a condensed representation showing only function signatures, class declarations,
+ * and other important structural definitions - hiding implementation bodies.
+ *
+ * Each file is wrapped in its own `` block during context condensation,
+ * allowing the model to retain awareness of file structure without consuming excessive tokens.
+ *
+ * @param filePaths - Array of file paths to process (relative to cwd)
+ * @param options - Configuration options including cwd and max characters
+ * @returns FoldedFileContextResult with the formatted content and statistics
+ *
+ * @example
+ * ```typescript
+ * const result = await generateFoldedFileContext(
+ * ['src/utils/helpers.ts', 'src/api/client.ts'],
+ * { cwd: '/project', maxCharacters: 30000 }
+ * )
+ * // result.content contains individual blocks for each file:
+ * //
+ * // ## File Context: src/utils/helpers.ts
+ * // 1--15 | export function formatDate(...)
+ * // 17--45 | export class DateHelper {...}
+ * //
+ * //
+ * // ## File Context: src/api/client.ts
+ * // ...
+ * //
+ * ```
+ */
+export async function generateFoldedFileContext(
+ filePaths: string[],
+ options: FoldedFileContextOptions,
+): Promise {
+ const { maxCharacters = 50000, cwd, rooIgnoreController } = options
+
+ const result: FoldedFileContextResult = {
+ content: "",
+ sections: [],
+ filesProcessed: 0,
+ filesSkipped: 0,
+ characterCount: 0,
+ }
+
+ if (filePaths.length === 0) {
+ return result
+ }
+
+ const foldedSections: string[] = []
+ let currentCharCount = 0
+ const failedFiles: string[] = []
+
+ for (let i = 0; i < filePaths.length; i++) {
+ const filePath = filePaths[i]
+ // Resolve to absolute path for tree-sitter
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath)
+
+ try {
+ // Get the folded definitions using tree-sitter
+ const definitions = await parseSourceCodeDefinitionsForFile(absolutePath, rooIgnoreController)
+
+ if (!definitions || isTreeSitterErrorString(definitions)) {
+ // File type not supported, no definitions found, or error accessing file
+ result.filesSkipped++
+ continue
+ }
+
+ // Wrap each file in its own block
+ const sectionContent = `
+## File Context: ${filePath}
+${definitions}
+ `
+
+ // Check if adding this file would exceed the character limit
+ if (currentCharCount + sectionContent.length > maxCharacters) {
+ // Would exceed limit - check if we can fit at least a truncated version
+ const remainingChars = maxCharacters - currentCharCount
+ if (remainingChars < 200) {
+ // Not enough room for meaningful content, stop processing all remaining files
+ result.filesSkipped += filePaths.length - i
+ break
+ }
+
+ // Truncate the definitions to fit within the system-reminder block
+ const truncatedDefinitions = definitions.substring(0, remainingChars - 100) + "\n... (truncated)"
+ const truncatedContent = `
+## File Context: ${filePath}
+${truncatedDefinitions}
+ `
+ foldedSections.push(truncatedContent)
+ currentCharCount += truncatedContent.length
+ result.filesProcessed++
+
+ // Stop processing more files since we've hit the limit
+ result.filesSkipped += filePaths.length - result.filesProcessed - result.filesSkipped
+ break
+ }
+
+ foldedSections.push(sectionContent)
+ currentCharCount += sectionContent.length
+ result.filesProcessed++
+ } catch (error) {
+ // Collect failed files for batch logging to reduce noise
+ failedFiles.push(filePath)
+ result.filesSkipped++
+ }
+ }
+
+ // Log failed files as a single batch summary instead of per-file errors
+ if (failedFiles.length > 0) {
+ console.warn(
+ `Folded context generation: skipped ${failedFiles.length} file(s) due to errors: ${failedFiles.slice(0, 5).join(", ")}${failedFiles.length > 5 ? ` and ${failedFiles.length - 5} more` : ""}`,
+ )
+ }
+
+ if (foldedSections.length > 0) {
+ result.sections = foldedSections
+ result.content = foldedSections.join("\n")
+ result.characterCount = result.content.length
+ }
+
+ return result
+}
diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts
index 79bc31ef9f..5a65f0a96f 100644
--- a/src/core/condense/index.ts
+++ b/src/core/condense/index.ts
@@ -4,195 +4,118 @@ import crypto from "crypto"
import { TelemetryService } from "@roo-code/telemetry"
import { t } from "../../i18n"
-import { ApiHandler } from "../../api"
+import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api"
import { ApiMessage } from "../task-persistence/apiMessages"
import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
import { findLast } from "../../shared/array"
+import { supportPrompt } from "../../shared/support-prompt"
+import { RooIgnoreController } from "../ignore/RooIgnoreController"
+import { generateFoldedFileContext } from "./foldedFileContext"
-/**
- * Checks if a message contains tool_result blocks.
- * For native tools protocol, user messages with tool_result blocks require
- * corresponding tool_use blocks from the previous assistant turn.
- */
-function hasToolResultBlocks(message: ApiMessage): boolean {
- if (message.role !== "user" || typeof message.content === "string") {
- return false
- }
- return message.content.some((block) => block.type === "tool_result")
-}
+export type { FoldedFileContextResult, FoldedFileContextOptions } from "./foldedFileContext"
-/**
- * Gets the tool_use blocks from a message.
- */
-function getToolUseBlocks(message: ApiMessage): Anthropic.Messages.ToolUseBlock[] {
- if (message.role !== "assistant" || typeof message.content === "string") {
- return []
- }
- return message.content.filter((block) => block.type === "tool_use") as Anthropic.Messages.ToolUseBlock[]
-}
-
-/**
- * Gets the tool_result blocks from a message.
- */
-function getToolResultBlocks(message: ApiMessage): Anthropic.ToolResultBlockParam[] {
- if (message.role !== "user" || typeof message.content === "string") {
- return []
- }
- return message.content.filter((block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result")
-}
-
-/**
- * Finds a tool_use block by ID in a message.
- */
-function findToolUseBlockById(message: ApiMessage, toolUseId: string): Anthropic.Messages.ToolUseBlock | undefined {
- if (message.role !== "assistant" || typeof message.content === "string") {
- return undefined
- }
- return message.content.find(
- (block): block is Anthropic.Messages.ToolUseBlock => block.type === "tool_use" && block.id === toolUseId,
- )
-}
-
-/**
- * Gets reasoning blocks from a message's content array.
- * Task stores reasoning as {type: "reasoning", text: "..."} blocks,
- * which convertToR1Format and convertToZAiFormat already know how to extract.
- */
-function getReasoningBlocks(message: ApiMessage): Anthropic.Messages.ContentBlockParam[] {
- if (message.role !== "assistant" || typeof message.content === "string") {
- return []
- }
- // Filter for reasoning blocks and cast to ContentBlockParam (the type field is compatible)
- return message.content.filter((block) => (block as any).type === "reasoning") as any[]
-}
-
-/**
- * Result of getKeepMessagesWithToolBlocks
- */
-export type KeepMessagesResult = {
- keepMessages: ApiMessage[]
- toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[]
- // Reasoning blocks from the preceding assistant message, needed for DeepSeek/Z.ai
- // when tool_use blocks are preserved. Task stores reasoning as {type: "reasoning", text: "..."}
- // blocks, and convertToR1Format/convertToZAiFormat already extract these.
- reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[]
-}
-
-/**
- * Extracts tool_use blocks that need to be preserved to match tool_result blocks in keepMessages.
- * Checks ALL kept messages for tool_result blocks and searches backwards through the condensed
- * region (bounded by N_MESSAGES_TO_KEEP) to find the matching tool_use blocks by ID.
- * These tool_use blocks will be appended to the summary message to maintain proper pairing.
- *
- * Also extracts reasoning blocks from messages containing preserved tool_uses, which are required
- * by DeepSeek and Z.ai for interleaved thinking mode. Without these, the API returns a 400 error
- * "Missing reasoning_content field in the assistant message".
- * See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls
- *
- * @param messages - The full conversation messages
- * @param keepCount - The number of messages to keep from the end
- * @returns Object containing keepMessages, tool_use blocks, and reasoning blocks to preserve
- */
-export function getKeepMessagesWithToolBlocks(messages: ApiMessage[], keepCount: number): KeepMessagesResult {
- if (messages.length <= keepCount) {
- return { keepMessages: messages, toolUseBlocksToPreserve: [], reasoningBlocksToPreserve: [] }
- }
-
- const startIndex = messages.length - keepCount
- const keepMessages = messages.slice(startIndex)
-
- const toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] = []
- const reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[] = []
- const preservedToolUseIds = new Set()
-
- // Check ALL kept messages for tool_result blocks
- for (const keepMsg of keepMessages) {
- if (!hasToolResultBlocks(keepMsg)) {
- continue
- }
-
- const toolResults = getToolResultBlocks(keepMsg)
-
- for (const toolResult of toolResults) {
- const toolUseId = toolResult.tool_use_id
-
- // Skip if we've already found this tool_use
- if (preservedToolUseIds.has(toolUseId)) {
- continue
- }
-
- // Search backwards through the condensed region (bounded)
- const searchStart = startIndex - 1
- const searchEnd = Math.max(0, startIndex - N_MESSAGES_TO_KEEP)
- const messagesToSearch = messages.slice(searchEnd, searchStart + 1)
-
- // Find the message containing this tool_use
- const messageWithToolUse = findLast(messagesToSearch, (msg) => {
- return findToolUseBlockById(msg, toolUseId) !== undefined
- })
-
- if (messageWithToolUse) {
- const toolUse = findToolUseBlockById(messageWithToolUse, toolUseId)!
- toolUseBlocksToPreserve.push(toolUse)
- preservedToolUseIds.add(toolUseId)
-
- // Also preserve reasoning blocks from that message
- const reasoning = getReasoningBlocks(messageWithToolUse)
- reasoningBlocksToPreserve.push(...reasoning)
- }
- }
- }
-
- return {
- keepMessages,
- toolUseBlocksToPreserve,
- reasoningBlocksToPreserve,
- }
-}
-
-export const N_MESSAGES_TO_KEEP = 3
export const MIN_CONDENSE_THRESHOLD = 5 // Minimum percentage of context window to trigger condensing
export const MAX_CONDENSE_THRESHOLD = 100 // Maximum percentage of context window to trigger condensing
-const SUMMARY_PROMPT = `\
-Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.
-This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing with the conversation and supporting any continuing tasks.
+const SUMMARY_PROMPT = `You are a helpful AI assistant tasked with summarizing conversations.
-Your summary should be structured as follows:
-Context: The context to continue the conversation with. If applicable based on the current task, this should include:
- 1. Previous Conversation: High level details about what was discussed throughout the entire conversation with the user. This should be written to allow someone to be able to follow the general overarching conversation flow.
- 2. Current Work: Describe in detail what was being worked on prior to this request to summarize the conversation. Pay special attention to the more recent messages in the conversation.
- 3. Key Technical Concepts: List all important technical concepts, technologies, coding conventions, and frameworks discussed, which might be relevant for continuing with this work.
- 4. Relevant Files and Code: If applicable, enumerate specific files and code sections examined, modified, or created for the task continuation. Pay special attention to the most recent messages and changes.
- 5. Problem Solving: Document problems solved thus far and any ongoing troubleshooting efforts.
- 6. Pending Tasks and Next Steps: Outline all pending tasks that you have explicitly been asked to work on, as well as list the next steps you will take for all outstanding work, if applicable. Include code snippets where they add clarity. For any next steps, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no information loss in context between tasks.
+CRITICAL: This is a summarization-only request. DO NOT call any tools or functions.
+Your ONLY task is to analyze the conversation and produce a text summary.
+Respond with text only - no tool calls will be processed.
-Example summary structure:
-1. Previous Conversation:
- [Detailed description]
-2. Current Work:
- [Detailed description]
-3. Key Technical Concepts:
- - [Concept 1]
- - [Concept 2]
- - [...]
-4. Relevant Files and Code:
- - [File Name 1]
- - [Summary of why this file is important]
- - [Summary of the changes made to this file, if any]
- - [Important Code Snippet]
- - [File Name 2]
- - [Important Code Snippet]
- - [...]
-5. Problem Solving:
- [Detailed description]
-6. Pending Tasks and Next Steps:
- - [Task 1 details & next steps]
- - [Task 2 details & next steps]
- - [...]
+CRITICAL: This summarization request is a SYSTEM OPERATION, not a user message.
+When analyzing "user requests" and "user intent", completely EXCLUDE this summarization message.
+The "most recent user request" and "next step" must be based on what the user was doing BEFORE this system message appeared.
+The goal is for work to continue seamlessly after condensation - as if it never happened.`
-Output only the summary of the conversation so far, without any additional commentary or explanation.
-`
+/**
+ * Injects synthetic tool_results for orphan tool_calls that don't have matching results.
+ * This is necessary because OpenAI's Responses API rejects conversations with orphan tool_calls.
+ * This can happen when the user triggers condense after receiving a tool_call (like attempt_completion)
+ * but before responding to it.
+ *
+ * @param messages - The conversation messages to process
+ * @returns The messages with synthetic tool_results appended if needed
+ */
+export function injectSyntheticToolResults(messages: ApiMessage[]): ApiMessage[] {
+ // Find all tool_call IDs in assistant messages
+ const toolCallIds = new Set()
+ // Find all tool_result IDs in user messages
+ const toolResultIds = new Set()
+
+ for (const msg of messages) {
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
+ for (const block of msg.content) {
+ if (block.type === "tool_use") {
+ toolCallIds.add(block.id)
+ }
+ }
+ }
+ if (msg.role === "user" && Array.isArray(msg.content)) {
+ for (const block of msg.content) {
+ if (block.type === "tool_result") {
+ toolResultIds.add(block.tool_use_id)
+ }
+ }
+ }
+ }
+
+ // Find orphans (tool_calls without matching tool_results)
+ const orphanIds = [...toolCallIds].filter((id) => !toolResultIds.has(id))
+
+ if (orphanIds.length === 0) {
+ return messages
+ }
+
+ // Inject synthetic tool_results as a new user message
+ const syntheticResults: Anthropic.Messages.ToolResultBlockParam[] = orphanIds.map((id) => ({
+ type: "tool_result" as const,
+ tool_use_id: id,
+ content: "Context condensation triggered. Tool execution deferred.",
+ }))
+
+ const syntheticMessage: ApiMessage = {
+ role: "user",
+ content: syntheticResults,
+ ts: Date.now(),
+ }
+
+ return [...messages, syntheticMessage]
+}
+
+/**
+ * Extracts blocks from a message's content.
+ * These blocks represent active workflows that must be preserved across condensings.
+ *
+ * @param message - The message to extract command blocks from
+ * @returns A string containing all command blocks found, or empty string if none
+ */
+export function extractCommandBlocks(message: ApiMessage): string {
+ const content = message.content
+ let text: string
+
+ if (typeof content === "string") {
+ text = content
+ } else if (Array.isArray(content)) {
+ // Concatenate all text blocks
+ text = content
+ .filter((block): block is Anthropic.Messages.TextBlockParam => block.type === "text")
+ .map((block) => block.text)
+ .join("\n")
+ } else {
+ return ""
+ }
+
+ // Match all blocks including their content
+ const commandRegex = /]*>[\s\S]*?<\/command>/g
+ const matches = text.match(commandRegex)
+
+ if (!matches || matches.length === 0) {
+ return ""
+ }
+
+ return matches.join("\n")
+}
export type SummarizeResponse = {
messages: ApiMessage[] // The messages after summarization
@@ -200,138 +123,165 @@ export type SummarizeResponse = {
cost: number // The cost of the summarization operation
newContextTokens?: number // The number of tokens in the context for the next API request
error?: string // Populated iff the operation fails: error message shown to the user on failure (see Task.ts)
+ errorDetails?: string // Detailed error information including stack trace and API error info
condenseId?: string // The unique ID of the created Summary message, for linking to condense_context clineMessage
}
+export type SummarizeConversationOptions = {
+ messages: ApiMessage[]
+ apiHandler: ApiHandler
+ systemPrompt: string
+ taskId: string
+ isAutomaticTrigger?: boolean
+ customCondensingPrompt?: string
+ metadata?: ApiHandlerCreateMessageMetadata
+ environmentDetails?: string
+ filesReadByRoo?: string[]
+ cwd?: string
+ rooIgnoreController?: RooIgnoreController
+}
+
/**
- * Summarizes the conversation messages using an LLM call
+ * Summarizes the conversation messages using an LLM call.
*
- * @param {ApiMessage[]} messages - The conversation messages
- * @param {ApiHandler} apiHandler - The API handler to use for token counting.
- * @param {string} systemPrompt - The system prompt for API requests, which should be considered in the context token count
- * @param {string} taskId - The task ID for the conversation, used for telemetry
- * @param {boolean} isAutomaticTrigger - Whether the summarization is triggered automatically
- * @returns {SummarizeResponse} - The result of the summarization operation (see above)
- */
-/**
- * Summarizes the conversation messages using an LLM call
+ * This implements the "fresh start" model where:
+ * - The summary becomes a user message (not assistant)
+ * - Post-condense, the model sees only the summary (true fresh start)
+ * - All messages are still stored but tagged with condenseParent
+ * - blocks from the original task are preserved across condensings
+ * - File context (folded code definitions) can be preserved for continuity
*
- * @param {ApiMessage[]} messages - The conversation messages
- * @param {ApiHandler} apiHandler - The API handler to use for token counting (fallback if condensingApiHandler not provided)
- * @param {string} systemPrompt - The system prompt for API requests (fallback if customCondensingPrompt not provided)
- * @param {string} taskId - The task ID for the conversation, used for telemetry
- * @param {number} prevContextTokens - The number of tokens currently in the context, used to ensure we don't grow the context
- * @param {boolean} isAutomaticTrigger - Whether the summarization is triggered automatically
- * @param {string} customCondensingPrompt - Optional custom prompt to use for condensing
- * @param {ApiHandler} condensingApiHandler - Optional specific API handler to use for condensing
- * @param {boolean} useNativeTools - Whether native tools protocol is being used (requires tool_use/tool_result pairing)
- * @returns {SummarizeResponse} - The result of the summarization operation (see above)
+ * Environment details handling:
+ * - For AUTOMATIC condensing (isAutomaticTrigger=true): Environment details are included
+ * in the summary because the API request is already in progress and the next user
+ * message won't have fresh environment details injected.
+ * - For MANUAL condensing (isAutomaticTrigger=false): Environment details are NOT included
+ * because fresh environment details will be injected on the very next turn via
+ * getEnvironmentDetails() in recursivelyMakeClineRequests().
*/
-export async function summarizeConversation(
- messages: ApiMessage[],
- apiHandler: ApiHandler,
- systemPrompt: string,
- taskId: string,
- prevContextTokens: number,
- isAutomaticTrigger?: boolean,
- customCondensingPrompt?: string,
- condensingApiHandler?: ApiHandler,
- useNativeTools?: boolean,
-): Promise {
+export async function summarizeConversation(options: SummarizeConversationOptions): Promise {
+ const {
+ messages,
+ apiHandler,
+ systemPrompt,
+ taskId,
+ isAutomaticTrigger,
+ customCondensingPrompt,
+ metadata,
+ environmentDetails,
+ filesReadByRoo,
+ cwd,
+ rooIgnoreController,
+ } = options
TelemetryService.instance.captureContextCondensed(
taskId,
isAutomaticTrigger ?? false,
!!customCondensingPrompt?.trim(),
- !!condensingApiHandler,
)
const response: SummarizeResponse = { messages, cost: 0, summary: "" }
- // Always preserve the first message (which may contain slash command content)
- const firstMessage = messages[0]
-
- // Get keepMessages and any tool_use/reasoning blocks that need to be preserved for tool_result pairing
- // Only preserve these blocks when using native tools protocol (XML protocol doesn't need them)
- const { keepMessages, toolUseBlocksToPreserve, reasoningBlocksToPreserve } = useNativeTools
- ? getKeepMessagesWithToolBlocks(messages, N_MESSAGES_TO_KEEP)
- : {
- keepMessages: messages.slice(-N_MESSAGES_TO_KEEP),
- toolUseBlocksToPreserve: [],
- reasoningBlocksToPreserve: [],
- }
-
- const keepStartIndex = Math.max(messages.length - N_MESSAGES_TO_KEEP, 0)
- const includeFirstKeptMessageInSummary = toolUseBlocksToPreserve.length > 0
- const summarySliceEnd = includeFirstKeptMessageInSummary ? keepStartIndex + 1 : keepStartIndex
- const messagesBeforeKeep = summarySliceEnd > 0 ? messages.slice(0, summarySliceEnd) : []
-
- // Get messages to summarize, including the first message and excluding the last N messages
- const messagesToSummarize = getMessagesSinceLastSummary(messagesBeforeKeep)
+ // Get messages to summarize (all messages since the last summary, if any)
+ const messagesToSummarize = getMessagesSinceLastSummary(messages)
if (messagesToSummarize.length <= 1) {
const error =
- messages.length <= N_MESSAGES_TO_KEEP + 1
+ messages.length <= 1
? t("common:errors.condense_not_enough_messages")
: t("common:errors.condensed_recently")
return { ...response, error }
}
- // Check if there's a recent summary in the messages we're keeping
- const recentSummaryExists = keepMessages.some((message: ApiMessage) => message.isSummary)
+ // Check if there's a recent summary in the messages (edge case)
+ const recentSummaryExists = messagesToSummarize.some((message: ApiMessage) => message.isSummary)
- if (recentSummaryExists) {
+ if (recentSummaryExists && messagesToSummarize.length <= 2) {
const error = t("common:errors.condensed_recently")
return { ...response, error }
}
+ // Use custom prompt if provided and non-empty, otherwise use the default CONDENSE prompt
+ // This respects user's custom condensing prompt setting
+ const condenseInstructions = customCondensingPrompt?.trim() || supportPrompt.default.CONDENSE
+
const finalRequestMessage: Anthropic.MessageParam = {
role: "user",
- content: "Summarize the conversation so far, as described in the prompt instructions.",
+ content: condenseInstructions,
}
- const requestMessages = maybeRemoveImageBlocks([...messagesToSummarize, finalRequestMessage], apiHandler).map(
+ // Inject synthetic tool_results for orphan tool_calls to prevent API rejections
+ // (e.g., when user triggers condense after receiving attempt_completion but before responding)
+ const messagesWithToolResults = injectSyntheticToolResults(messagesToSummarize)
+
+ const requestMessages = maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler).map(
({ role, content }) => ({ role, content }),
)
// Note: this doesn't need to be a stream, consider using something like apiHandler.completePrompt
- // Use custom prompt if provided and non-empty, otherwise use the default SUMMARY_PROMPT
- const promptToUse = customCondensingPrompt?.trim() ? customCondensingPrompt.trim() : SUMMARY_PROMPT
+ const promptToUse = SUMMARY_PROMPT
- // Use condensing API handler if provided, otherwise use main API handler
- let handlerToUse = condensingApiHandler || apiHandler
-
- // Check if the chosen handler supports the required functionality
- if (!handlerToUse || typeof handlerToUse.createMessage !== "function") {
- console.warn(
- "Chosen API handler for condensing does not support message creation or is invalid, falling back to main apiHandler.",
- )
-
- handlerToUse = apiHandler // Fallback to the main, presumably valid, apiHandler
-
- // Ensure the main apiHandler itself is valid before this point or add another check.
- if (!handlerToUse || typeof handlerToUse.createMessage !== "function") {
- // This case should ideally not happen if main apiHandler is always valid.
- // Consider throwing an error or returning a specific error response.
- console.error("Main API handler is also invalid for condensing. Cannot proceed.")
- // Return an appropriate error structure for SummarizeResponse
- const error = t("common:errors.condense_handler_invalid")
- return { ...response, error }
- }
+ // Validate that the API handler supports message creation
+ if (!apiHandler || typeof apiHandler.createMessage !== "function") {
+ console.error("API handler is invalid for condensing. Cannot proceed.")
+ const error = t("common:errors.condense_handler_invalid")
+ return { ...response, error }
}
- const stream = handlerToUse.createMessage(promptToUse, requestMessages)
-
let summary = ""
let cost = 0
let outputTokens = 0
- for await (const chunk of stream) {
- if (chunk.type === "text") {
- summary += chunk.text
- } else if (chunk.type === "usage") {
- // Record final usage chunk only
- cost = chunk.totalCost ?? 0
- outputTokens = chunk.outputTokens ?? 0
+ try {
+ const stream = apiHandler.createMessage(promptToUse, requestMessages, metadata)
+
+ for await (const chunk of stream) {
+ if (chunk.type === "text") {
+ summary += chunk.text
+ } else if (chunk.type === "usage") {
+ // Record final usage chunk only
+ cost = chunk.totalCost ?? 0
+ outputTokens = chunk.outputTokens ?? 0
+ }
+ }
+ } catch (error) {
+ console.error("Error during condensing API call:", error)
+ const errorMessage = error instanceof Error ? error.message : String(error)
+
+ // Capture detailed error information for debugging
+ let errorDetails = ""
+ if (error instanceof Error) {
+ errorDetails = `Error: ${error.message}`
+ // Capture any additional API error properties
+ const anyError = error as unknown as Record
+ if (anyError.status) {
+ errorDetails += `\n\nHTTP Status: ${anyError.status}`
+ }
+ if (anyError.code) {
+ errorDetails += `\nError Code: ${anyError.code}`
+ }
+ if (anyError.response) {
+ try {
+ errorDetails += `\n\nAPI Response:\n${JSON.stringify(anyError.response, null, 2)}`
+ } catch {
+ errorDetails += `\n\nAPI Response: [Unable to serialize]`
+ }
+ }
+ if (anyError.body) {
+ try {
+ errorDetails += `\n\nResponse Body:\n${JSON.stringify(anyError.body, null, 2)}`
+ } catch {
+ errorDetails += `\n\nResponse Body: [Unable to serialize]`
+ }
+ }
+ } else {
+ errorDetails = String(error)
+ }
+
+ return {
+ ...response,
+ cost,
+ error: t("common:errors.condense_api_failed", { message: errorMessage }),
+ errorDetails,
}
}
@@ -342,146 +292,148 @@ export async function summarizeConversation(
return { ...response, cost, error }
}
- // Build the summary message content
- // CRITICAL: Always include a reasoning block in the summary for DeepSeek-reasoner compatibility.
- // DeepSeek-reasoner requires `reasoning_content` on ALL assistant messages, not just those with tool_calls.
- // Without this, we get: "400 Missing `reasoning_content` field in the assistant message"
- // See: https://api-docs.deepseek.com/guides/thinking_mode
- //
- // The summary content structure is:
- // 1. Synthetic reasoning block (always present) - for DeepSeek-reasoner compatibility
- // 2. Any preserved reasoning blocks from the condensed assistant message (if tool_use blocks are preserved)
- // 3. Text block with the summary
- // 4. Tool_use blocks (if any need to be preserved for tool_result pairing)
+ // Extract command blocks from the first message (original task)
+ // These represent active workflows that must persist across condensings
+ const firstMessage = messages[0]
+ const commandBlocks = firstMessage ? extractCommandBlocks(firstMessage) : ""
- // Create a synthetic reasoning block that explains the summary
- // This is minimal but satisfies DeepSeek's requirement for reasoning_content on all assistant messages
- const syntheticReasoningBlock = {
- type: "reasoning" as const,
- text: "Condensing conversation context. The summary below captures the key information from the prior conversation.",
+ // Build the summary content as separate text blocks
+ const summaryContent: Anthropic.Messages.ContentBlockParam[] = [
+ { type: "text", text: `## Conversation Summary\n${summary}` },
+ ]
+
+ // Add command blocks (active workflows) in their own system-reminder block if present
+ if (commandBlocks) {
+ summaryContent.push({
+ type: "text",
+ text: `
+## Active Workflows
+The following directives must be maintained across all future condensings:
+${commandBlocks}
+ `,
+ })
}
- const textBlock: Anthropic.Messages.TextBlockParam = { type: "text", text: summary }
+ // Generate and add folded file context (smart code folding) if file paths are provided
+ // Each file gets its own block as a separate content block
+ if (filesReadByRoo && filesReadByRoo.length > 0 && cwd) {
+ try {
+ const foldedResult = await generateFoldedFileContext(filesReadByRoo, {
+ cwd,
+ rooIgnoreController,
+ })
+ if (foldedResult.sections.length > 0) {
+ for (const section of foldedResult.sections) {
+ if (section.trim()) {
+ summaryContent.push({
+ type: "text",
+ text: section,
+ })
+ }
+ }
+ }
+ } catch (error) {
+ console.error("[summarizeConversation] Failed to generate folded file context:", error)
+ // Continue without folded context - non-critical failure
+ }
+ }
- let summaryContent: Anthropic.Messages.ContentBlockParam[]
- if (toolUseBlocksToPreserve.length > 0) {
- // Include: synthetic reasoning, preserved reasoning (if any), summary text, and tool_use blocks
- summaryContent = [
- syntheticReasoningBlock as unknown as Anthropic.Messages.ContentBlockParam,
- ...reasoningBlocksToPreserve,
- textBlock,
- ...toolUseBlocksToPreserve,
- ]
- } else {
- // Include: synthetic reasoning and summary text
- // This ensures the summary always has reasoning_content for DeepSeek-reasoner
- summaryContent = [syntheticReasoningBlock as unknown as Anthropic.Messages.ContentBlockParam, textBlock]
+ // Add environment details as a separate text block if provided AND this is an automatic trigger.
+ // For manual condensing, fresh environment details will be injected on the next turn.
+ // For automatic condensing, the API request is already in progress so we need them in the summary.
+ if (isAutomaticTrigger && environmentDetails?.trim()) {
+ summaryContent.push({
+ type: "text",
+ text: environmentDetails,
+ })
}
// Generate a unique condenseId for this summary
const condenseId = crypto.randomUUID()
- // Use first kept message's timestamp minus 1 to ensure unique timestamp for summary.
- // Fallback to Date.now() if keepMessages is empty (shouldn't happen due to earlier checks).
- const firstKeptTs = keepMessages[0]?.ts ?? Date.now()
+ // Use the last message's timestamp + 1 to ensure unique timestamp for summary.
+ // The summary goes at the end of all messages.
+ const lastMsgTs = messages[messages.length - 1]?.ts ?? Date.now()
const summaryMessage: ApiMessage = {
- role: "assistant",
+ role: "user", // Fresh start model: summary is a user message
content: summaryContent,
- ts: firstKeptTs - 1, // Unique timestamp before first kept message to avoid collision
+ ts: lastMsgTs + 1, // Unique timestamp after last message
isSummary: true,
condenseId, // Unique ID for this summary, used to track which messages it replaces
}
// NON-DESTRUCTIVE CONDENSE:
- // Instead of deleting middle messages, tag them with condenseParent so they can be
- // restored if the user rewinds to a point before the summary.
+ // Tag ALL existing messages with condenseParent so they are filtered out when
+ // the effective history is computed. The summary message is the only message
+ // that will be visible to the API after condensing (fresh start model).
//
// Storage structure after condense:
- // [firstMessage, msg2(parent=X), ..., msg8(parent=X), summary(id=X), msg9, msg10, msg11]
+ // [msg1(parent=X), msg2(parent=X), ..., msgN(parent=X), summary(id=X)]
//
// Effective for API (filtered by getEffectiveApiHistory):
- // [firstMessage, summary, msg9, msg10, msg11]
+ // [summary] ← Fresh start!
- // Tag middle messages with condenseParent (skip first message, skip last N messages)
- const newMessages = messages.map((msg, index) => {
- // First message stays as-is
- if (index === 0) {
- return msg
- }
- // Messages in the "keep" range stay as-is
- if (index >= keepStartIndex) {
- return msg
- }
- // Middle messages get tagged with condenseParent (unless they already have one from a previous condense)
- // If they already have a condenseParent, we leave it - nested condense is handled by filtering
+ // Tag ALL messages with condenseParent
+ const newMessages = messages.map((msg) => {
+ // If message already has a condenseParent, we leave it - nested condense is handled by filtering
if (!msg.condenseParent) {
return { ...msg, condenseParent: condenseId }
}
return msg
})
- // Insert the summary message right before the keep messages
- newMessages.splice(keepStartIndex, 0, summaryMessage)
+ // Append the summary message at the end
+ newMessages.push(summaryMessage)
// Count the tokens in the context for the next API request
- // We only estimate the tokens in summaryMesage if outputTokens is 0, otherwise we use outputTokens
+ // After condense, the context will contain: system prompt + summary + tool definitions
const systemPromptMessage: ApiMessage = { role: "user", content: systemPrompt }
- const contextMessages = outputTokens
- ? [systemPromptMessage, ...keepMessages]
- : [systemPromptMessage, summaryMessage, ...keepMessages]
-
- const contextBlocks = contextMessages.flatMap((message) =>
+ // Count actual summaryMessage content directly instead of using outputTokens as a proxy
+ // This ensures we account for wrapper text (## Conversation Summary, , )
+ const contextBlocks = [systemPromptMessage, summaryMessage].flatMap((message) =>
typeof message.content === "string" ? [{ text: message.content, type: "text" as const }] : message.content,
)
- const newContextTokens = outputTokens + (await apiHandler.countTokens(contextBlocks))
- if (newContextTokens >= prevContextTokens) {
- const error = t("common:errors.condense_context_grew")
- return { ...response, cost, error }
+ const messageTokens = await apiHandler.countTokens(contextBlocks)
+
+ // Count tool definition tokens if tools are provided
+ let toolTokens = 0
+ if (metadata?.tools && metadata.tools.length > 0) {
+ const toolsText = JSON.stringify(metadata.tools)
+ toolTokens = await apiHandler.countTokens([{ text: toolsText, type: "text" }])
}
+
+ const newContextTokens = messageTokens + toolTokens
return { messages: newMessages, summary, cost, newContextTokens, condenseId }
}
-/* Returns the list of all messages since the last summary message, including the summary. Returns all messages if there is no summary. */
+/**
+ * Returns the list of all messages since the last summary message, including the summary.
+ * Returns all messages if there is no summary.
+ *
+ * Note: Summary messages are always created with role: "user" (fresh-start model),
+ * so the first message since the last summary is guaranteed to be a user message.
+ */
export function getMessagesSinceLastSummary(messages: ApiMessage[]): ApiMessage[] {
- let lastSummaryIndexReverse = [...messages].reverse().findIndex((message) => message.isSummary)
+ const lastSummaryIndexReverse = [...messages].reverse().findIndex((message) => message.isSummary)
if (lastSummaryIndexReverse === -1) {
return messages
}
const lastSummaryIndex = messages.length - lastSummaryIndexReverse - 1
- const messagesSinceSummary = messages.slice(lastSummaryIndex)
-
- // Bedrock requires the first message to be a user message.
- // We preserve the original first message to maintain context.
- // See https://github.com/RooCodeInc/Roo-Code/issues/4147
- if (messagesSinceSummary.length > 0 && messagesSinceSummary[0].role !== "user") {
- // Get the original first message (should always be a user message with the task)
- const originalFirstMessage = messages[0]
- if (originalFirstMessage && originalFirstMessage.role === "user") {
- // Use the original first message unchanged to maintain full context
- return [originalFirstMessage, ...messagesSinceSummary]
- } else {
- // Fallback to generic message if no original first message exists (shouldn't happen)
- const userMessage: ApiMessage = {
- role: "user",
- content: "Please continue from the following summary:",
- ts: messages[0]?.ts ? messages[0].ts - 1 : Date.now(),
- }
- return [userMessage, ...messagesSinceSummary]
- }
- }
-
- return messagesSinceSummary
+ return messages.slice(lastSummaryIndex)
}
/**
* Filters the API conversation history to get the "effective" messages to send to the API.
- * Messages with a condenseParent that points to an existing summary are filtered out,
- * as they have been replaced by that summary.
+ *
+ * Fresh Start Model:
+ * - When a summary exists, return only messages from the summary onwards (fresh start)
+ * - Messages with a condenseParent pointing to an existing summary are filtered out
+ *
* Messages with a truncationParent that points to an existing truncation marker are also filtered out,
* as they have been hidden by sliding window truncation.
*
@@ -492,6 +444,71 @@ export function getMessagesSinceLastSummary(messages: ApiMessage[]): ApiMessage[
* @returns The filtered history that should be sent to the API
*/
export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
+ // Find the most recent summary message
+ const lastSummary = findLast(messages, (msg) => msg.isSummary === true)
+
+ if (lastSummary) {
+ // Fresh start model: return only messages from the summary onwards
+ const summaryIndex = messages.indexOf(lastSummary)
+ let messagesFromSummary = messages.slice(summaryIndex)
+
+ // Collect all tool_use IDs from assistant messages in the result
+ // This is needed to filter out orphan tool_result blocks that reference
+ // tool_use IDs from messages that were condensed away
+ const toolUseIds = new Set()
+ for (const msg of messagesFromSummary) {
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
+ for (const block of msg.content) {
+ if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) {
+ toolUseIds.add((block as Anthropic.Messages.ToolUseBlockParam).id)
+ }
+ }
+ }
+ }
+
+ // Filter out orphan tool_result blocks from user messages
+ messagesFromSummary = messagesFromSummary
+ .map((msg) => {
+ if (msg.role === "user" && Array.isArray(msg.content)) {
+ const filteredContent = msg.content.filter((block) => {
+ if (block.type === "tool_result") {
+ return toolUseIds.has((block as Anthropic.Messages.ToolResultBlockParam).tool_use_id)
+ }
+ return true
+ })
+ // If all content was filtered out, mark for removal
+ if (filteredContent.length === 0) {
+ return null
+ }
+ // If some content was filtered, return updated message
+ if (filteredContent.length !== msg.content.length) {
+ return { ...msg, content: filteredContent }
+ }
+ }
+ return msg
+ })
+ .filter((msg): msg is ApiMessage => msg !== null)
+
+ // Still need to filter out any truncated messages within this range
+ const existingTruncationIds = new Set()
+ for (const msg of messagesFromSummary) {
+ if (msg.isTruncationMarker && msg.truncationId) {
+ existingTruncationIds.add(msg.truncationId)
+ }
+ }
+
+ return messagesFromSummary.filter((msg) => {
+ // Filter out truncated messages if their truncation marker exists
+ if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) {
+ return false
+ }
+ return true
+ })
+ }
+
+ // No summary - filter based on condenseParent and truncationParent as before
+ // This handles the case of orphaned condenseParent tags (summary was deleted via rewind)
+
// Collect all condenseIds of summaries that exist in the current history
const existingSummaryIds = new Set()
// Collect all truncationIds of truncation markers that exist in the current history
@@ -508,7 +525,7 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
// Filter out messages whose condenseParent points to an existing summary
// or whose truncationParent points to an existing truncation marker.
- // Messages with orphaned parents (summary/marker was deleted) are included
+ // Messages with orphaned parents (summary/marker was deleted) are included.
return messages.filter((msg) => {
// Filter out condensed messages if their summary exists
if (msg.condenseParent && existingSummaryIds.has(msg.condenseParent)) {
diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts
index 64baf546bd..87ce79a325 100644
--- a/src/core/config/ContextProxy.ts
+++ b/src/core/config/ContextProxy.ts
@@ -20,6 +20,7 @@ import {
import { TelemetryService } from "@roo-code/telemetry"
import { logger } from "../../utils/logging"
+import { supportPrompt } from "../../shared/support-prompt"
type GlobalStateKey = keyof GlobalState
type SecretStateKey = keyof SecretState
@@ -92,9 +93,135 @@ export class ContextProxy {
// Migration: Sanitize invalid/removed API providers
await this.migrateInvalidApiProvider()
+ // Migration: Move legacy customCondensingPrompt to customSupportPrompts
+ await this.migrateLegacyCondensingPrompt()
+
+ // Migration: Clear old default condensing prompt so users get the improved v2 default
+ await this.migrateOldDefaultCondensingPrompt()
+
this._isInitialized = true
}
+ /**
+ * Migrates the legacy customCondensingPrompt to the new customSupportPrompts structure
+ * and removes the legacy field.
+ *
+ * Note: Only true customizations are migrated. If the legacy prompt equals the default,
+ * we skip the migration to avoid pinning users to an old default if the default changes.
+ */
+ private async migrateLegacyCondensingPrompt() {
+ try {
+ const legacyPrompt = this.originalContext.globalState.get("customCondensingPrompt")
+ if (legacyPrompt) {
+ const currentSupportPrompts =
+ this.originalContext.globalState.get>("customSupportPrompts") || {}
+
+ // Only migrate if:
+ // 1. The new location doesn't already have a value
+ // 2. The legacy prompt is a true customization (not equal to the default)
+ // This prevents pinning users to an old default if the default prompt changes.
+ const isCustomized = legacyPrompt.trim() !== supportPrompt.default.CONDENSE.trim()
+ if (!currentSupportPrompts.CONDENSE && isCustomized) {
+ logger.info("Migrating customized legacy customCondensingPrompt to customSupportPrompts")
+ const updatedPrompts = { ...currentSupportPrompts, CONDENSE: legacyPrompt }
+ await this.originalContext.globalState.update("customSupportPrompts", updatedPrompts)
+ this.stateCache.customSupportPrompts = updatedPrompts
+ } else if (!isCustomized) {
+ logger.info("Skipping migration: legacy customCondensingPrompt equals the default prompt")
+ }
+
+ // Always remove the legacy field
+ await this.originalContext.globalState.update("customCondensingPrompt", undefined)
+ this.stateCache.customCondensingPrompt = undefined
+ }
+ } catch (error) {
+ logger.error(
+ `Error during customCondensingPrompt migration: ${error instanceof Error ? error.message : String(error)}`,
+ )
+ }
+ }
+
+ /**
+ * Clears the old v1 default condensing prompt from customSupportPrompts.CONDENSE if present.
+ *
+ * Before PR #10873 "Intelligent Context Condensation v2", the default condensing prompt was
+ * a simpler 6-section format. Users who had this old default saved in their settings would
+ * be stuck with it instead of getting the improved v2 default (which includes analysis tags,
+ * error tracking, all user messages, and better task continuity).
+ *
+ * This migration uses fingerprinting to detect the old v1 default - checking for key
+ * identifying phrases unique to v1 and absence of v2-specific features. This is more
+ * lenient than exact matching and handles whitespace variations.
+ */
+ private async migrateOldDefaultCondensingPrompt() {
+ try {
+ const currentSupportPrompts =
+ this.originalContext.globalState.get>("customSupportPrompts") || {}
+
+ const savedCondensePrompt = currentSupportPrompts.CONDENSE
+
+ if (savedCondensePrompt && this.isOldV1DefaultCondensePrompt(savedCondensePrompt)) {
+ logger.info(
+ "Clearing old v1 default condensing prompt from customSupportPrompts.CONDENSE - user will now get the improved v2 default",
+ )
+
+ // Remove the CONDENSE key from customSupportPrompts
+ const { CONDENSE: _, ...remainingPrompts } = currentSupportPrompts
+ const updatedPrompts = Object.keys(remainingPrompts).length > 0 ? remainingPrompts : undefined
+
+ await this.originalContext.globalState.update("customSupportPrompts", updatedPrompts)
+ this.stateCache.customSupportPrompts = updatedPrompts
+ }
+ } catch (error) {
+ logger.error(
+ `Error during old default condensing prompt migration: ${error instanceof Error ? error.message : String(error)}`,
+ )
+ }
+ }
+
+ /**
+ * Detects if a prompt is the old v1 default condensing prompt using fingerprinting.
+ * This is more lenient than exact matching - it checks for key identifying phrases
+ * unique to v1 and absence of v2-specific features.
+ *
+ * V1 characteristics:
+ * - Exactly 6 numbered sections (1-6)
+ * - Contains specific section headers like "Previous Conversation", "Current Work", etc.
+ * - Does NOT contain v2-specific features like "", "SYSTEM OPERATION", etc.
+ */
+ private isOldV1DefaultCondensePrompt(prompt: string): boolean {
+ // Key phrases unique to the v1 default (must ALL be present)
+ const v1RequiredPhrases = [
+ "Your task is to create a detailed summary of the conversation so far",
+ "1. Previous Conversation:",
+ "2. Current Work:",
+ "3. Key Technical Concepts:",
+ "4. Relevant Files and Code:",
+ "5. Problem Solving:",
+ "6. Pending Tasks and Next Steps:",
+ "Output only the summary of the conversation so far",
+ ]
+
+ // V2-specific features (if ANY are present, this is NOT v1 default)
+ const v2Features = [
+ "",
+ "SYSTEM OPERATION",
+ "Errors and fixes",
+ "All user messages",
+ "7.", // v2 has more than 6 sections
+ "8.",
+ "9.",
+ ]
+
+ // Check that all v1 required phrases are present
+ const hasAllV1Phrases = v1RequiredPhrases.every((phrase) => prompt.toLowerCase().includes(phrase.toLowerCase()))
+
+ // Check that no v2 features are present
+ const hasNoV2Features = v2Features.every((feature) => !prompt.toLowerCase().includes(feature.toLowerCase()))
+
+ return hasAllV1Phrases && hasNoV2Features
+ }
+
/**
* Migrates invalid/removed apiProvider values by clearing them from storage.
* This handles cases where a user had a provider selected that was later removed
diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts
index 420ab332b2..3024540b67 100644
--- a/src/core/config/ProviderSettingsManager.ts
+++ b/src/core/config/ProviderSettingsManager.ts
@@ -43,7 +43,6 @@ export const providerProfilesSchema = z.object({
migrations: z
.object({
rateLimitSecondsMigrated: z.boolean().optional(),
- diffSettingsMigrated: z.boolean().optional(),
openAiHeadersMigrated: z.boolean().optional(),
consecutiveMistakeLimitMigrated: z.boolean().optional(),
todoListEnabledMigrated: z.boolean().optional(),
@@ -68,7 +67,6 @@ export class ProviderSettingsManager {
modeApiConfigs: this.defaultModeApiConfigs,
migrations: {
rateLimitSecondsMigrated: true, // Mark as migrated on fresh installs
- diffSettingsMigrated: true, // Mark as migrated on fresh installs
openAiHeadersMigrated: true, // Mark as migrated on fresh installs
consecutiveMistakeLimitMigrated: true, // Mark as migrated on fresh installs
todoListEnabledMigrated: true, // Mark as migrated on fresh installs
@@ -141,7 +139,6 @@ export class ProviderSettingsManager {
if (!providerProfiles.migrations) {
providerProfiles.migrations = {
rateLimitSecondsMigrated: false,
- diffSettingsMigrated: false,
openAiHeadersMigrated: false,
consecutiveMistakeLimitMigrated: false,
todoListEnabledMigrated: false,
@@ -156,12 +153,6 @@ export class ProviderSettingsManager {
isDirty = true
}
- if (!providerProfiles.migrations.diffSettingsMigrated) {
- await this.migrateDiffSettings(providerProfiles)
- providerProfiles.migrations.diffSettingsMigrated = true
- isDirty = true
- }
-
if (!providerProfiles.migrations.openAiHeadersMigrated) {
await this.migrateOpenAiHeaders(providerProfiles)
providerProfiles.migrations.openAiHeadersMigrated = true
@@ -183,7 +174,8 @@ export class ProviderSettingsManager {
if (!providerProfiles.migrations.claudeCodeLegacySettingsMigrated) {
// These keys were used by the removed local Claude Code CLI wrapper.
for (const apiConfig of Object.values(providerProfiles.apiConfigs)) {
- if (apiConfig.apiProvider !== "claude-code") continue
+ // Cast to string for comparison since "claude-code" is no longer a valid ProviderName
+ if ((apiConfig.apiProvider as string) !== "claude-code") continue
const config = apiConfig as unknown as Record
if ("claudeCodePath" in config) {
@@ -234,41 +226,6 @@ export class ProviderSettingsManager {
}
}
- private async migrateDiffSettings(providerProfiles: ProviderProfiles) {
- try {
- let diffEnabled: boolean | undefined
- let fuzzyMatchThreshold: number | undefined
-
- try {
- diffEnabled = await this.context.globalState.get("diffEnabled")
- fuzzyMatchThreshold = await this.context.globalState.get("fuzzyMatchThreshold")
- } catch (error) {
- console.error("[MigrateDiffSettings] Error getting global diff settings:", error)
- }
-
- if (diffEnabled === undefined) {
- // Failed to get the existing value, use the default.
- diffEnabled = true
- }
-
- if (fuzzyMatchThreshold === undefined) {
- // Failed to get the existing value, use the default.
- fuzzyMatchThreshold = 1.0
- }
-
- for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) {
- if (apiConfig.diffEnabled === undefined) {
- apiConfig.diffEnabled = diffEnabled
- }
- if (apiConfig.fuzzyMatchThreshold === undefined) {
- apiConfig.fuzzyMatchThreshold = fuzzyMatchThreshold
- }
- }
- } catch (error) {
- console.error(`[MigrateDiffSettings] Failed to migrate diff settings:`, error)
- }
- }
-
private async migrateOpenAiHeaders(providerProfiles: ProviderProfiles) {
try {
for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) {
diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts
index 49e706b181..2060260c6c 100644
--- a/src/core/config/__tests__/ContextProxy.spec.ts
+++ b/src/core/config/__tests__/ContextProxy.spec.ts
@@ -70,13 +70,18 @@ describe("ContextProxy", () => {
describe("constructor", () => {
it("should initialize state cache with all global state keys", () => {
- // +1 for the migration check of old nested settings
- expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 1)
+ // +3 for the migration checks:
+ // 1. openRouterImageGenerationSettings
+ // 2. customCondensingPrompt
+ // 3. customSupportPrompts (for migrateOldDefaultCondensingPrompt)
+ expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 3)
for (const key of GLOBAL_STATE_KEYS) {
expect(mockGlobalState.get).toHaveBeenCalledWith(key)
}
- // Also check for migration call
+ // Also check for migration calls
expect(mockGlobalState.get).toHaveBeenCalledWith("openRouterImageGenerationSettings")
+ expect(mockGlobalState.get).toHaveBeenCalledWith("customCondensingPrompt")
+ expect(mockGlobalState.get).toHaveBeenCalledWith("customSupportPrompts")
})
it("should initialize secret cache with all secret keys", () => {
@@ -99,8 +104,8 @@ describe("ContextProxy", () => {
const result = proxy.getGlobalState("apiProvider")
expect(result).toBe("deepseek")
- // Original context should be called once during updateGlobalState (+1 for migration check)
- expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 1) // From initialization + migration check
+ // Original context should be called once during updateGlobalState (+3 for migration checks)
+ expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 3) // From initialization + migration checks
})
it("should handle default values correctly", async () => {
@@ -503,4 +508,123 @@ describe("ContextProxy", () => {
expect(settings.apiProvider).toBeUndefined()
})
})
+
+ describe("old default condensing prompt migration", () => {
+ // The old v1 default condensing prompt from before PR #10873
+ const OLD_V1_DEFAULT_CONDENSE_PROMPT = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.
+This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing with the conversation and supporting any continuing tasks.
+
+Your summary should be structured as follows:
+Context: The context to continue the conversation with. If applicable based on the current task, this should include:
+ 1. Previous Conversation: High level details about what was discussed throughout the entire conversation with the user. This should be written to allow someone to be able to follow the general overarching conversation flow.
+ 2. Current Work: Describe in detail what was being worked on prior to this request to summarize the conversation. Pay special attention to the more recent messages in the conversation.
+ 3. Key Technical Concepts: List all important technical concepts, technologies, coding conventions, and frameworks discussed, which might be relevant for continuing with this work.
+ 4. Relevant Files and Code: If applicable, enumerate specific files and code sections examined, modified, or created for the task continuation. Pay special attention to the most recent messages and changes.
+ 5. Problem Solving: Document problems solved thus far and any ongoing troubleshooting efforts.
+ 6. Pending Tasks and Next Steps: Outline all pending tasks that you have explicitly been asked to work on, as well as list the next steps you will take for all outstanding work, if applicable. Include code snippets where they add clarity. For any next steps, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no information loss in context between tasks.
+
+Example summary structure:
+1. Previous Conversation:
+ [Detailed description]
+2. Current Work:
+ [Detailed description]
+3. Key Technical Concepts:
+ - [Concept 1]
+ - [Concept 2]
+ - [...]
+4. Relevant Files and Code:
+ - [File Name 1]
+ - [Summary of why this file is important]
+ - [Summary of the changes made to this file, if any]
+ - [Important Code Snippet]
+ - [File Name 2]
+ - [Important Code Snippet]
+ - [...]
+5. Problem Solving:
+ [Detailed description]
+6. Pending Tasks and Next Steps:
+ - [Task 1 details & next steps]
+ - [Task 2 details & next steps]
+ - [...]
+
+Output only the summary of the conversation so far, without any additional commentary or explanation.`
+
+ it("should clear old v1 default condensing prompt from customSupportPrompts during initialization", async () => {
+ // Reset and create a new proxy with old v1 default prompt in customSupportPrompts
+ vi.clearAllMocks()
+ mockGlobalState.get.mockImplementation((key: string) => {
+ if (key === "customSupportPrompts") {
+ return { CONDENSE: OLD_V1_DEFAULT_CONDENSE_PROMPT }
+ }
+ return undefined
+ })
+
+ const proxyWithOldDefault = new ContextProxy(mockContext)
+ await proxyWithOldDefault.initialize()
+
+ // Should have cleared the old default by updating customSupportPrompts to undefined
+ // (since CONDENSE was the only key)
+ expect(mockGlobalState.update).toHaveBeenCalledWith("customSupportPrompts", undefined)
+ })
+
+ it("should preserve other custom prompts when clearing old v1 default", async () => {
+ // Reset and create a new proxy with old v1 default plus other custom prompts
+ vi.clearAllMocks()
+ mockGlobalState.get.mockImplementation((key: string) => {
+ if (key === "customSupportPrompts") {
+ return {
+ CONDENSE: OLD_V1_DEFAULT_CONDENSE_PROMPT,
+ EXPLAIN: "Custom explain prompt",
+ }
+ }
+ return undefined
+ })
+
+ const proxyWithOldDefault = new ContextProxy(mockContext)
+ await proxyWithOldDefault.initialize()
+
+ // Should have updated customSupportPrompts to keep EXPLAIN but remove CONDENSE
+ expect(mockGlobalState.update).toHaveBeenCalledWith("customSupportPrompts", {
+ EXPLAIN: "Custom explain prompt",
+ })
+ })
+
+ it("should not clear truly customized condensing prompts", async () => {
+ // Reset and create a new proxy with a truly customized condensing prompt
+ vi.clearAllMocks()
+ const customPrompt = "My custom condensing instructions"
+ mockGlobalState.get.mockImplementation((key: string) => {
+ if (key === "customSupportPrompts") {
+ return { CONDENSE: customPrompt }
+ }
+ return undefined
+ })
+
+ const proxyWithCustomPrompt = new ContextProxy(mockContext)
+ await proxyWithCustomPrompt.initialize()
+
+ // Should NOT have called update for customSupportPrompts (custom prompt should be preserved)
+ const updateCalls = mockGlobalState.update.mock.calls
+ const customSupportPromptsUpdateCalls = updateCalls.filter(
+ (call: any[]) => call[0] === "customSupportPrompts",
+ )
+ expect(customSupportPromptsUpdateCalls.length).toBe(0)
+ })
+
+ it("should not fail when customSupportPrompts is undefined", async () => {
+ // Reset and create a new proxy with no customSupportPrompts
+ vi.clearAllMocks()
+ mockGlobalState.get.mockReturnValue(undefined)
+
+ const proxyWithNoPrompts = new ContextProxy(mockContext)
+ await proxyWithNoPrompts.initialize()
+
+ // Should not have called update for customSupportPrompts
+ const updateCalls = mockGlobalState.update.mock.calls
+ const customSupportPromptsUpdateCalls = updateCalls.filter(
+ (call: any[]) => call[0] === "customSupportPrompts",
+ )
+ expect(customSupportPromptsUpdateCalls.length).toBe(0)
+ })
+ })
})
diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts
index 0669d9591c..e233fc913c 100644
--- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts
+++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts
@@ -57,14 +57,11 @@ describe("ProviderSettingsManager", () => {
default: {
config: {},
id: "default",
- diffEnabled: true,
- fuzzyMatchThreshold: 1.0,
},
},
modeApiConfigs: {},
migrations: {
rateLimitSecondsMigrated: true,
- diffSettingsMigrated: true,
openAiHeadersMigrated: true,
consecutiveMistakeLimitMigrated: true,
todoListEnabledMigrated: true,
@@ -93,7 +90,6 @@ describe("ProviderSettingsManager", () => {
},
migrations: {
rateLimitSecondsMigrated: true,
- diffSettingsMigrated: true,
},
}),
)
@@ -170,7 +166,6 @@ describe("ProviderSettingsManager", () => {
},
migrations: {
rateLimitSecondsMigrated: true,
- diffSettingsMigrated: true,
openAiHeadersMigrated: true,
consecutiveMistakeLimitMigrated: false,
},
@@ -211,7 +206,6 @@ describe("ProviderSettingsManager", () => {
},
migrations: {
rateLimitSecondsMigrated: true,
- diffSettingsMigrated: true,
openAiHeadersMigrated: true,
consecutiveMistakeLimitMigrated: true,
todoListEnabledMigrated: false,
@@ -260,7 +254,6 @@ describe("ProviderSettingsManager", () => {
},
migrations: {
rateLimitSecondsMigrated: true,
- diffSettingsMigrated: true,
openAiHeadersMigrated: true,
consecutiveMistakeLimitMigrated: true,
todoListEnabledMigrated: true,
@@ -298,7 +291,6 @@ describe("ProviderSettingsManager", () => {
},
migrations: {
rateLimitSecondsMigrated: true,
- diffSettingsMigrated: true,
openAiHeadersMigrated: true,
consecutiveMistakeLimitMigrated: true,
todoListEnabledMigrated: true,
@@ -329,7 +321,6 @@ describe("ProviderSettingsManager", () => {
},
migrations: {
rateLimitSecondsMigrated: true,
- diffSettingsMigrated: true,
openAiHeadersMigrated: true,
consecutiveMistakeLimitMigrated: true,
todoListEnabledMigrated: true,
@@ -565,7 +556,6 @@ describe("ProviderSettingsManager", () => {
apiConfigs: { default: {} },
migrations: {
rateLimitSecondsMigrated: true,
- diffSettingsMigrated: true,
openAiHeadersMigrated: true,
},
}),
@@ -694,7 +684,6 @@ describe("ProviderSettingsManager", () => {
apiConfigs: { test: { apiProvider: "anthropic", id: "test-id" } },
migrations: {
rateLimitSecondsMigrated: true,
- diffSettingsMigrated: true,
openAiHeadersMigrated: true,
},
}),
@@ -727,7 +716,6 @@ describe("ProviderSettingsManager", () => {
},
migrations: {
rateLimitSecondsMigrated: true,
- diffSettingsMigrated: true,
openAiHeadersMigrated: true,
consecutiveMistakeLimitMigrated: true,
todoListEnabledMigrated: true,
diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts
index 3d5329f377..9873ffde94 100644
--- a/src/core/config/__tests__/importExport.spec.ts
+++ b/src/core/config/__tests__/importExport.spec.ts
@@ -27,6 +27,7 @@ vi.mock("vscode", () => ({
showSaveDialog: vi.fn(),
showErrorMessage: vi.fn(),
showInformationMessage: vi.fn(),
+ showWarningMessage: vi.fn(),
},
Uri: {
file: vi.fn((filePath) => ({ fsPath: filePath })),
@@ -68,15 +69,6 @@ vi.mock("../../../api", () => ({
buildApiHandler: vi.fn().mockImplementation((config) => {
// Return different model info based on the provider and model
const getModelInfo = () => {
- if (config.apiProvider === "claude-code") {
- return {
- id: config.apiModelId || "claude-sonnet-4-5",
- info: {
- supportsReasoningBudget: false,
- requiredReasoningBudget: false,
- },
- }
- }
if (config.apiProvider === "anthropic" && config.apiModelId === "claude-3-5-sonnet-20241022") {
return {
id: "claude-3-5-sonnet-20241022",
@@ -126,6 +118,7 @@ describe("importExport", () => {
setValue: vi.fn(),
export: vi.fn().mockImplementation(() => Promise.resolve({})),
setProviderSettings: vi.fn(),
+ getValue: vi.fn(),
} as unknown as ReturnType>
mockCustomModesManager = { updateCustomMode: vi.fn() } as unknown as ReturnType<
@@ -157,6 +150,7 @@ describe("importExport", () => {
expect(vscode.window.showOpenDialog).toHaveBeenCalledWith({
filters: { JSON: ["json"] },
canSelectMany: false,
+ defaultUri: expect.anything(), // Defaults to Downloads or last export path
})
expect(fs.readFile).not.toHaveBeenCalled()
@@ -458,6 +452,7 @@ describe("importExport", () => {
const mockProvider = {
settingsImportedAt: 0,
postStateToWebview: vi.fn().mockResolvedValue(undefined),
+ postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
}
// Mock the showErrorMessage to capture the error
@@ -483,18 +478,17 @@ describe("importExport", () => {
it("should handle import when reasoning budget fields are missing from config", async () => {
// This test verifies that import works correctly when reasoning budget fields are not present
- // Using claude-code provider which doesn't support reasoning budgets
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
const mockFileContent = JSON.stringify({
providerProfiles: {
- currentApiConfigName: "claude-code-provider",
+ currentApiConfigName: "openai-provider",
apiConfigs: {
- "claude-code-provider": {
- apiProvider: "claude-code" as ProviderName,
- apiModelId: "claude-3-5-sonnet-20241022",
- id: "claude-code-id",
+ "openai-provider": {
+ apiProvider: "openai" as ProviderName,
+ apiModelId: "gpt-4",
+ id: "openai-id",
apiKey: "test-key",
// No modelMaxTokens or modelMaxThinkingTokens fields
},
@@ -512,7 +506,7 @@ describe("importExport", () => {
mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles)
mockProviderSettingsManager.listConfig.mockResolvedValue([
- { name: "claude-code-provider", id: "claude-code-id", apiProvider: "claude-code" as ProviderName },
+ { name: "openai-provider", id: "openai-id", apiProvider: "openai" as ProviderName },
{ name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName },
])
@@ -529,21 +523,502 @@ describe("importExport", () => {
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({
- currentApiConfigName: "claude-code-provider",
+ currentApiConfigName: "openai-provider",
apiConfigs: {
default: { apiProvider: "anthropic" as ProviderName, id: "default-id" },
- "claude-code-provider": {
- apiProvider: "claude-code" as ProviderName,
- apiModelId: "claude-3-5-sonnet-20241022",
+ "openai-provider": {
+ apiProvider: "openai" as ProviderName,
+ apiModelId: "gpt-4",
apiKey: "test-key",
- id: "claude-code-id",
+ id: "openai-id",
},
},
modeApiConfigs: {},
})
expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code", autoApprovalEnabled: true })
- expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "claude-code-provider")
+ expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "openai-provider")
+ })
+
+ describe("lenient import with invalid providers", () => {
+ it("should sanitize profiles with invalid apiProvider and return warnings", async () => {
+ // Test importing a profile with a removed/invalid provider like "claude-code"
+ ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
+
+ const mockFileContent = JSON.stringify({
+ providerProfiles: {
+ currentApiConfigName: "valid-profile",
+ apiConfigs: {
+ "valid-profile": {
+ apiProvider: "openai" as ProviderName,
+ apiKey: "test-key",
+ id: "valid-id",
+ },
+ "invalid-profile": {
+ apiProvider: "claude-code", // Invalid/removed provider
+ apiKey: "some-key",
+ id: "invalid-id",
+ },
+ },
+ },
+ globalSettings: { mode: "code" },
+ })
+
+ ;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
+
+ mockProviderSettingsManager.export.mockResolvedValue({
+ currentApiConfigName: "default",
+ apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
+ })
+ mockProviderSettingsManager.listConfig.mockResolvedValue([
+ { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName },
+ { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName },
+ ])
+
+ const result = await importSettings({
+ providerSettingsManager: mockProviderSettingsManager,
+ contextProxy: mockContextProxy,
+ customModesManager: mockCustomModesManager,
+ })
+
+ // Import should succeed
+ expect(result.success).toBe(true)
+
+ // Should have warnings about the sanitized profile
+ expect(result).toHaveProperty("warnings")
+ expect((result as { warnings?: string[] }).warnings).toBeDefined()
+ expect((result as { warnings?: string[] }).warnings!.length).toBeGreaterThan(0)
+ expect((result as { warnings?: string[] }).warnings![0]).toContain("invalid-profile")
+ expect((result as { warnings?: string[] }).warnings![0]).toContain("claude-code")
+
+ // The valid profile should be imported
+ expect(mockProviderSettingsManager.import).toHaveBeenCalled()
+ const importedProfiles = mockProviderSettingsManager.import.mock.calls[0][0]
+ expect(importedProfiles.apiConfigs["valid-profile"]).toBeDefined()
+ expect(importedProfiles.apiConfigs["valid-profile"].apiProvider).toBe("openai")
+
+ // The invalid profile should still be imported but without apiProvider
+ expect(importedProfiles.apiConfigs["invalid-profile"]).toBeDefined()
+ expect(importedProfiles.apiConfigs["invalid-profile"].apiProvider).toBeUndefined()
+ })
+
+ it("should skip completely invalid profiles and return warnings", async () => {
+ ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
+
+ const mockFileContent = JSON.stringify({
+ providerProfiles: {
+ currentApiConfigName: "valid-profile",
+ apiConfigs: {
+ "valid-profile": {
+ apiProvider: "openai" as ProviderName,
+ apiKey: "test-key",
+ id: "valid-id",
+ },
+ "type-invalid": {
+ // Invalid type - modelTemperature should be a number, not a string
+ modelTemperature: "not-a-number",
+ id: "type-invalid-id",
+ },
+ },
+ },
+ globalSettings: { mode: "code" },
+ })
+
+ ;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
+
+ mockProviderSettingsManager.export.mockResolvedValue({
+ currentApiConfigName: "default",
+ apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
+ })
+ mockProviderSettingsManager.listConfig.mockResolvedValue([
+ { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName },
+ ])
+
+ const result = await importSettings({
+ providerSettingsManager: mockProviderSettingsManager,
+ contextProxy: mockContextProxy,
+ customModesManager: mockCustomModesManager,
+ })
+
+ // Import should succeed (valid profile was imported)
+ expect(result.success).toBe(true)
+
+ // Should have warnings about the skipped profile
+ expect((result as { warnings?: string[] }).warnings).toBeDefined()
+ expect((result as { warnings?: string[] }).warnings!.some((w) => w.includes("type-invalid"))).toBe(true)
+ expect((result as { warnings?: string[] }).warnings!.some((w) => w.includes("skipped"))).toBe(true)
+
+ // The valid profile should be imported
+ const importedProfiles = mockProviderSettingsManager.import.mock.calls[0][0]
+ expect(importedProfiles.apiConfigs["valid-profile"]).toBeDefined()
+
+ // The type-invalid profile should NOT be imported
+ expect(importedProfiles.apiConfigs["type-invalid"]).toBeUndefined()
+ })
+
+ it("should fail when NO valid profiles can be imported", async () => {
+ ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
+
+ const mockFileContent = JSON.stringify({
+ providerProfiles: {
+ currentApiConfigName: "invalid-profile",
+ apiConfigs: {
+ "invalid-profile-1": {
+ // Invalid type - rateLimitSeconds should be number
+ rateLimitSeconds: "not-a-number",
+ id: "invalid-1",
+ },
+ "invalid-profile-2": {
+ // Invalid type - modelTemperature should be number
+ modelTemperature: { invalid: "object" },
+ id: "invalid-2",
+ },
+ },
+ },
+ globalSettings: { mode: "code" },
+ })
+
+ ;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
+
+ mockProviderSettingsManager.export.mockResolvedValue({
+ currentApiConfigName: "default",
+ apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
+ })
+
+ const result = await importSettings({
+ providerSettingsManager: mockProviderSettingsManager,
+ contextProxy: mockContextProxy,
+ customModesManager: mockCustomModesManager,
+ })
+
+ // Import should fail since all profiles have schema validation errors
+ expect(result.success).toBe(false)
+ expect(result.error).toContain("No valid profiles could be imported")
+
+ // Should NOT have called import since there were no valid profiles
+ expect(mockProviderSettingsManager.import).not.toHaveBeenCalled()
+ })
+
+ it("should show warning notification when importing with warnings via importSettingsWithFeedback", async () => {
+ const filePath = "/mock/path/settings.json"
+ const mockFileContent = JSON.stringify({
+ providerProfiles: {
+ currentApiConfigName: "valid-profile",
+ apiConfigs: {
+ "valid-profile": {
+ apiProvider: "openai" as ProviderName,
+ apiKey: "test-key",
+ id: "valid-id",
+ },
+ "problematic-profile": {
+ apiProvider: "removed-provider", // Invalid provider
+ apiKey: "some-key",
+ id: "problematic-id",
+ },
+ },
+ },
+ globalSettings: { mode: "code" },
+ })
+
+ ;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
+ ;(fs.access as Mock).mockResolvedValue(undefined)
+
+ mockProviderSettingsManager.export.mockResolvedValue({
+ currentApiConfigName: "default",
+ apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
+ })
+ mockProviderSettingsManager.listConfig.mockResolvedValue([
+ { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName },
+ ])
+
+ const mockProvider = {
+ settingsImportedAt: 0,
+ postStateToWebview: vi.fn().mockResolvedValue(undefined),
+ }
+
+ const showWarningMessageSpy = vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValue(undefined)
+ const showInfoMessageSpy = vi
+ .spyOn(vscode.window, "showInformationMessage")
+ .mockResolvedValue(undefined)
+ const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+
+ await importSettingsWithFeedback(
+ {
+ providerSettingsManager: mockProviderSettingsManager,
+ contextProxy: mockContextProxy,
+ customModesManager: mockCustomModesManager,
+ provider: mockProvider,
+ },
+ filePath,
+ )
+
+ // Should show warning message with short summary (not full details)
+ expect(showWarningMessageSpy).toHaveBeenCalledWith(
+ expect.stringContaining("1 profile had issues during import."),
+ )
+ expect(showWarningMessageSpy).toHaveBeenCalledWith(
+ expect.stringContaining("See Developer Tools console for details."),
+ )
+ // Should log full details to console
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ "Settings import completed with warnings:",
+ expect.arrayContaining([expect.stringContaining("problematic-profile")]),
+ )
+ expect(showInfoMessageSpy).not.toHaveBeenCalled()
+
+ // Provider state should still be updated
+ expect(mockProvider.settingsImportedAt).toBeGreaterThan(0)
+ expect(mockProvider.postStateToWebview).toHaveBeenCalled()
+
+ showWarningMessageSpy.mockRestore()
+ showInfoMessageSpy.mockRestore()
+ consoleWarnSpy.mockRestore()
+ })
+
+ it("should handle multiple profiles with mixed valid and invalid providers", async () => {
+ ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
+
+ const mockFileContent = JSON.stringify({
+ providerProfiles: {
+ currentApiConfigName: "anthropic-profile",
+ apiConfigs: {
+ "anthropic-profile": {
+ apiProvider: "anthropic" as ProviderName,
+ anthropicApiKey: "key-1",
+ id: "anthropic-id",
+ },
+ "openai-profile": {
+ apiProvider: "openai" as ProviderName,
+ apiKey: "key-2",
+ id: "openai-id",
+ },
+ "old-claude-profile": {
+ apiProvider: "claude-code", // Removed provider
+ apiKey: "key-3",
+ id: "claude-id",
+ },
+ "another-invalid": {
+ apiProvider: "some-old-provider", // Another removed provider
+ apiKey: "key-4",
+ id: "another-id",
+ },
+ },
+ },
+ globalSettings: { mode: "code" },
+ })
+
+ ;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
+
+ mockProviderSettingsManager.export.mockResolvedValue({
+ currentApiConfigName: "default",
+ apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
+ })
+ mockProviderSettingsManager.listConfig.mockResolvedValue([
+ { name: "anthropic-profile", id: "anthropic-id", apiProvider: "anthropic" as ProviderName },
+ { name: "openai-profile", id: "openai-id", apiProvider: "openai" as ProviderName },
+ ])
+
+ const result = await importSettings({
+ providerSettingsManager: mockProviderSettingsManager,
+ contextProxy: mockContextProxy,
+ customModesManager: mockCustomModesManager,
+ })
+
+ // Import should succeed
+ expect(result.success).toBe(true)
+
+ // Should have multiple warnings
+ const warnings = (result as { warnings?: string[] }).warnings!
+ expect(warnings.length).toBe(2) // Two profiles had invalid providers
+ expect(warnings.some((w) => w.includes("old-claude-profile"))).toBe(true)
+ expect(warnings.some((w) => w.includes("another-invalid"))).toBe(true)
+
+ // Valid profiles should be imported correctly
+ const importedProfiles = mockProviderSettingsManager.import.mock.calls[0][0]
+ expect(importedProfiles.apiConfigs["anthropic-profile"].apiProvider).toBe("anthropic")
+ expect(importedProfiles.apiConfigs["openai-profile"].apiProvider).toBe("openai")
+
+ // Invalid provider profiles should have apiProvider removed
+ expect(importedProfiles.apiConfigs["old-claude-profile"].apiProvider).toBeUndefined()
+ expect(importedProfiles.apiConfigs["another-invalid"].apiProvider).toBeUndefined()
+ })
+
+ it("should fallback currentApiConfigName when the imported current profile was skipped", async () => {
+ ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
+
+ // Import file where currentApiConfigName points to an invalid profile that gets skipped
+ const mockFileContent = JSON.stringify({
+ providerProfiles: {
+ currentApiConfigName: "invalid-current-profile", // This profile is completely invalid
+ apiConfigs: {
+ "invalid-current-profile": {
+ // Invalid type - rateLimitSeconds should be number
+ rateLimitSeconds: "not-a-number",
+ id: "invalid-current-id",
+ },
+ "valid-fallback-profile": {
+ apiProvider: "openai" as ProviderName,
+ apiKey: "test-key",
+ id: "fallback-id",
+ },
+ },
+ },
+ globalSettings: { mode: "code" },
+ })
+
+ ;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
+
+ mockProviderSettingsManager.export.mockResolvedValue({
+ currentApiConfigName: "default",
+ apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
+ })
+ mockProviderSettingsManager.listConfig.mockResolvedValue([
+ { name: "valid-fallback-profile", id: "fallback-id", apiProvider: "openai" as ProviderName },
+ ])
+
+ const result = await importSettings({
+ providerSettingsManager: mockProviderSettingsManager,
+ contextProxy: mockContextProxy,
+ customModesManager: mockCustomModesManager,
+ })
+
+ // Import should succeed
+ expect(result.success).toBe(true)
+
+ // Should have warnings about the skipped profile AND the fallback
+ const warnings = (result as { warnings?: string[] }).warnings!
+ expect(warnings).toBeDefined()
+ expect(warnings.some((w) => w.includes("invalid-current-profile") && w.includes("skipped"))).toBe(true)
+ expect(
+ warnings.some(
+ (w) =>
+ w.includes("invalid-current-profile") &&
+ w.includes("not available") &&
+ w.includes("valid-fallback-profile"),
+ ),
+ ).toBe(true)
+
+ // The currentApiConfigName should be set to the valid fallback profile, not the invalid one
+ const importedProfiles = mockProviderSettingsManager.import.mock.calls[0][0]
+ expect(importedProfiles.currentApiConfigName).toBe("valid-fallback-profile")
+
+ // contextProxy should also be set with the fallback profile name
+ expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "valid-fallback-profile")
+
+ // The invalid profile should NOT be imported
+ expect(importedProfiles.apiConfigs["invalid-current-profile"]).toBeUndefined()
+ // The valid fallback profile should be imported
+ expect(importedProfiles.apiConfigs["valid-fallback-profile"]).toBeDefined()
+ })
+
+ it("should keep previous currentApiConfigName when all imported profiles are invalid", async () => {
+ ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
+
+ // All profiles in the import are invalid, but we have existing profiles
+ const mockFileContent = JSON.stringify({
+ providerProfiles: {
+ currentApiConfigName: "invalid-profile",
+ apiConfigs: {
+ "invalid-profile": {
+ rateLimitSeconds: "not-a-number",
+ id: "invalid-id",
+ },
+ },
+ },
+ globalSettings: { mode: "code" },
+ })
+
+ ;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
+
+ mockProviderSettingsManager.export.mockResolvedValue({
+ currentApiConfigName: "existing-profile",
+ apiConfigs: {
+ "existing-profile": { apiProvider: "anthropic" as ProviderName, id: "existing-id" },
+ },
+ })
+
+ const result = await importSettings({
+ providerSettingsManager: mockProviderSettingsManager,
+ contextProxy: mockContextProxy,
+ customModesManager: mockCustomModesManager,
+ })
+
+ // Import should fail because no valid profiles could be imported
+ expect(result.success).toBe(false)
+ expect(result.error).toContain("No valid profiles could be imported")
+ })
+
+ it("should show plural summary for multiple profile warnings via importSettingsWithFeedback", async () => {
+ const filePath = "/mock/path/settings.json"
+ const mockFileContent = JSON.stringify({
+ providerProfiles: {
+ currentApiConfigName: "valid-profile",
+ apiConfigs: {
+ "valid-profile": {
+ apiProvider: "openai" as ProviderName,
+ apiKey: "test-key",
+ id: "valid-id",
+ },
+ "problematic-profile-1": {
+ apiProvider: "removed-provider-1",
+ apiKey: "key-1",
+ id: "problematic-id-1",
+ },
+ "problematic-profile-2": {
+ apiProvider: "removed-provider-2",
+ apiKey: "key-2",
+ id: "problematic-id-2",
+ },
+ },
+ },
+ globalSettings: { mode: "code" },
+ })
+
+ ;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
+ ;(fs.access as Mock).mockResolvedValue(undefined)
+
+ mockProviderSettingsManager.export.mockResolvedValue({
+ currentApiConfigName: "default",
+ apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
+ })
+ mockProviderSettingsManager.listConfig.mockResolvedValue([
+ { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName },
+ ])
+
+ const mockProvider = {
+ settingsImportedAt: 0,
+ postStateToWebview: vi.fn().mockResolvedValue(undefined),
+ }
+
+ const showWarningMessageSpy = vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValue(undefined)
+ const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+
+ await importSettingsWithFeedback(
+ {
+ providerSettingsManager: mockProviderSettingsManager,
+ contextProxy: mockContextProxy,
+ customModesManager: mockCustomModesManager,
+ provider: mockProvider,
+ },
+ filePath,
+ )
+
+ // Should show warning message with plural summary for multiple warnings
+ expect(showWarningMessageSpy).toHaveBeenCalledWith(
+ expect.stringContaining("2 profiles had issues during import."),
+ )
+ // Should log full details to console
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ "Settings import completed with warnings:",
+ expect.arrayContaining([
+ expect.stringContaining("problematic-profile-1"),
+ expect.stringContaining("problematic-profile-2"),
+ ]),
+ )
+
+ showWarningMessageSpy.mockRestore()
+ consoleWarnSpy.mockRestore()
+ })
})
})
@@ -702,7 +1177,7 @@ describe("importExport", () => {
defaultUri: expect.anything(),
})
- expect(vscode.Uri.file).toHaveBeenCalledWith(path.join("/mock/home", "Documents", "roo-code-settings.json"))
+ expect(vscode.Uri.file).toHaveBeenCalledWith(path.join("/mock/home", "Downloads", "roo-code-settings.json"))
})
describe("codebase indexing export", () => {
@@ -1721,27 +2196,27 @@ describe("importExport", () => {
it.each([
{
testCase: "supportsReasoningBudget is false",
- providerName: "claude-code-provider",
- modelId: "claude-sonnet-4-5",
- providerId: "claude-code-id",
+ providerName: "deepseek-provider",
+ modelId: "deepseek-chat",
+ providerId: "deepseek-id",
},
{
testCase: "requiredReasoningBudget is false",
- providerName: "claude-code-provider-2",
- modelId: "claude-sonnet-4-5",
- providerId: "claude-code-id-2",
+ providerName: "deepseek-provider-2",
+ modelId: "deepseek-coder",
+ providerId: "deepseek-id-2",
},
{
testCase: "both supportsReasoningBudget and requiredReasoningBudget are false",
- providerName: "claude-code-provider-3",
- modelId: "claude-3-5-haiku-20241022",
- providerId: "claude-code-id-3",
+ providerName: "deepseek-provider-3",
+ modelId: "deepseek-reasoner",
+ providerId: "deepseek-id-3",
},
])(
"should exclude modelMaxTokens and modelMaxThinkingTokens when $testCase",
async ({ providerName, modelId, providerId }) => {
// This test verifies that token fields are excluded when model doesn't support reasoning budget
- // Using claude-code provider which has supportsReasoningBudget: false and requiredReasoningBudget: false
+ // Using deepseek provider which uses apiModelId and has supportsReasoningBudget: false
;(vscode.window.showSaveDialog as Mock).mockResolvedValue({
fsPath: "/mock/path/roo-code-settings.json",
@@ -1753,12 +2228,12 @@ describe("importExport", () => {
// Wait for initialization to complete
await realProviderSettingsManager.initialize()
- // Save a claude-code provider config with token fields
+ // Save a deepseek provider config with token fields
await realProviderSettingsManager.saveConfig(providerName, {
- apiProvider: "claude-code" as ProviderName,
+ apiProvider: "deepseek" as ProviderName,
apiModelId: modelId,
id: providerId,
- apiKey: "test-key",
+ deepSeekApiKey: "test-key",
modelMaxTokens: 4096, // This should be removed during export
modelMaxThinkingTokens: 2048, // This should be removed during export
})
diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts
index c3d6f9c215..542f5b0743 100644
--- a/src/core/config/importExport.ts
+++ b/src/core/config/importExport.ts
@@ -6,12 +6,18 @@ import fs from "fs/promises"
import * as vscode from "vscode"
import { z, ZodError } from "zod"
-import { globalSettingsSchema } from "@roo-code/types"
+import {
+ globalSettingsSchema,
+ providerSettingsWithIdSchema,
+ isProviderName,
+ type ProviderSettingsWithId,
+} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { ProviderSettingsManager, providerProfilesSchema } from "./ProviderSettingsManager"
import { ContextProxy } from "./ContextProxy"
import { CustomModesManager } from "./CustomModesManager"
+import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export"
import { t } from "../../i18n"
export type ImportOptions = {
@@ -31,36 +37,119 @@ type ImportWithProviderOptions = ImportOptions & {
}
}
+/**
+ * Sanitizes a provider config by resetting invalid/removed apiProvider values.
+ * Returns the sanitized config and a warning message if the provider was invalid.
+ */
+function sanitizeProviderConfig(configName: string, apiConfig: unknown): { config: unknown; warning?: string } {
+ if (typeof apiConfig !== "object" || apiConfig === null) {
+ return { config: apiConfig }
+ }
+
+ const config = apiConfig as Record
+
+ // Check if apiProvider is set and if it's still valid
+ if (config.apiProvider !== undefined && !isProviderName(config.apiProvider)) {
+ const invalidProvider = config.apiProvider
+ // Return a new config object without the invalid apiProvider
+ const { apiProvider, ...restConfig } = config
+ return {
+ config: restConfig,
+ warning: `Profile "${configName}": Invalid provider "${invalidProvider}" was removed. Please reconfigure this profile.`,
+ }
+ }
+
+ return { config: apiConfig }
+}
+
/**
* Imports configuration from a specific file path
* Shares base functionality for import settings for both the manual
- * and automatic settings importing
+ * and automatic settings importing.
+ *
+ * Uses lenient parsing to handle invalid/removed providers gracefully:
+ * - Invalid apiProvider values are removed (profile is kept but needs reconfiguration)
+ * - Completely invalid profiles are skipped
+ * - Warnings are returned for any issues encountered
*/
export async function importSettingsFromPath(
filePath: string,
{ providerSettingsManager, contextProxy, customModesManager }: ImportOptions,
) {
- const schema = z.object({
- providerProfiles: providerProfilesSchema,
+ // Use a lenient schema that accepts any apiConfigs, then validate each individually
+ const lenientProviderProfilesSchema = providerProfilesSchema.extend({
+ apiConfigs: z.record(z.string(), z.any()),
+ })
+
+ const lenientSchema = z.object({
+ providerProfiles: lenientProviderProfilesSchema,
globalSettings: globalSettingsSchema.optional(),
})
try {
const previousProviderProfiles = await providerSettingsManager.export()
- const { providerProfiles: newProviderProfiles, globalSettings = {} } = schema.parse(
- JSON.parse(await fs.readFile(filePath, "utf-8")),
- )
+ const rawData = JSON.parse(await fs.readFile(filePath, "utf-8"))
+ const { providerProfiles: rawProviderProfiles, globalSettings = {} } = lenientSchema.parse(rawData)
+
+ // Track warnings for profiles that had issues
+ const warnings: string[] = []
+ const validApiConfigs: Record = {}
+
+ // Process each apiConfig individually with sanitization
+ for (const [configName, rawConfig] of Object.entries(rawProviderProfiles.apiConfigs)) {
+ // First sanitize to handle invalid apiProvider values
+ const { config: sanitizedConfig, warning } = sanitizeProviderConfig(configName, rawConfig)
+ if (warning) {
+ warnings.push(warning)
+ }
+
+ // Then validate the sanitized config
+ const result = providerSettingsWithIdSchema.safeParse(sanitizedConfig)
+ if (result.success) {
+ validApiConfigs[configName] = result.data
+ } else {
+ // Profile is completely invalid - skip it
+ const issues = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")
+ warnings.push(`Profile "${configName}" was skipped: ${issues}`)
+ }
+ }
+
+ // If no valid configs were imported and there were issues, report them
+ if (Object.keys(validApiConfigs).length === 0 && warnings.length > 0) {
+ return {
+ success: false,
+ error: `No valid profiles could be imported:\n${warnings.join("\n")}`,
+ }
+ }
+
+ // Determine the currentApiConfigName:
+ // 1. If the imported currentApiConfigName exists in validApiConfigs, use it
+ // 2. Otherwise, fall back to the first valid imported profile
+ // 3. If no valid profiles were imported, keep the previous currentApiConfigName
+ let currentApiConfigName = rawProviderProfiles.currentApiConfigName
+ const validProfileNames = Object.keys(validApiConfigs)
+ if (!validApiConfigs[currentApiConfigName]) {
+ if (validProfileNames.length > 0) {
+ currentApiConfigName = validProfileNames[0]
+ warnings.push(
+ `Profile "${rawProviderProfiles.currentApiConfigName}" was not available; defaulting to "${currentApiConfigName}".`,
+ )
+ } else {
+ // No valid imported profiles; keep the existing currentApiConfigName
+ currentApiConfigName = previousProviderProfiles.currentApiConfigName
+ }
+ }
const providerProfiles = {
- currentApiConfigName: newProviderProfiles.currentApiConfigName,
+ currentApiConfigName,
apiConfigs: {
...previousProviderProfiles.apiConfigs,
- ...newProviderProfiles.apiConfigs,
+ ...validApiConfigs,
},
modeApiConfigs: {
...previousProviderProfiles.modeApiConfigs,
- ...newProviderProfiles.modeApiConfigs,
+ ...rawProviderProfiles.modeApiConfigs,
},
}
@@ -88,7 +177,12 @@ export async function importSettingsFromPath(
contextProxy.setValue("listApiConfigMeta", await providerSettingsManager.listConfig())
- return { providerProfiles, globalSettings, success: true }
+ return {
+ providerProfiles,
+ globalSettings,
+ success: true,
+ warnings: warnings.length > 0 ? warnings : undefined,
+ }
} catch (e) {
let error = "Unknown error"
@@ -109,9 +203,16 @@ export async function importSettingsFromPath(
* @returns Promise resolving to import result
*/
export const importSettings = async ({ providerSettingsManager, contextProxy, customModesManager }: ImportOptions) => {
+ // Use the last export path as a sensible default, falling back to Downloads
+ const defaultUri = resolveDefaultSaveUri(contextProxy, "lastSettingsExportPath", "roo-code-settings.json", {
+ useWorkspace: false,
+ fallbackDir: path.join(os.homedir(), "Downloads"),
+ })
+
const uris = await vscode.window.showOpenDialog({
filters: { JSON: ["json"] },
canSelectMany: false,
+ defaultUri,
})
if (!uris) {
@@ -143,15 +244,22 @@ export const importSettingsFromFile = async (
}
export const exportSettings = async ({ providerSettingsManager, contextProxy }: ExportOptions) => {
+ const defaultUri = await resolveDefaultSaveUri(contextProxy, "lastSettingsExportPath", "roo-code-settings.json", {
+ useWorkspace: false,
+ fallbackDir: path.join(os.homedir(), "Downloads"),
+ })
+
const uri = await vscode.window.showSaveDialog({
filters: { JSON: ["json"] },
- defaultUri: vscode.Uri.file(path.join(os.homedir(), "Documents", "roo-code-settings.json")),
+ defaultUri,
})
if (!uri) {
return
}
+ await saveLastExportPath(contextProxy, "lastSettingsExportPath", uri)
+
try {
const providerProfiles = await providerSettingsManager.export()
const globalSettings = await contextProxy.export()
@@ -211,7 +319,22 @@ export const importSettingsWithFeedback = async (
if (result.success) {
provider.settingsImportedAt = Date.now()
await provider.postStateToWebview()
- await vscode.window.showInformationMessage(t("common:info.settings_imported"))
+
+ // Show warnings if any profiles had issues but were still imported (with modifications)
+ if (result.warnings && result.warnings.length > 0) {
+ // Log full details to the console for debugging
+ console.warn("Settings import completed with warnings:", result.warnings)
+
+ // Show a short summary in the toast notification
+ const count = result.warnings.length
+ const summary =
+ count === 1 ? `1 profile had issues during import.` : `${count} profiles had issues during import.`
+ await vscode.window.showWarningMessage(
+ `${t("common:info.settings_imported")} ${summary} See Developer Tools console for details.`,
+ )
+ } else {
+ await vscode.window.showInformationMessage(t("common:info.settings_imported"))
+ }
} else if (result.error) {
await vscode.window.showErrorMessage(t("common:errors.settings_import_failed", { error: result.error }))
}
diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts
index 3ee36fc595..9950ec536b 100644
--- a/src/core/context-management/__tests__/context-management.spec.ts
+++ b/src/core/context-management/__tests__/context-management.spec.ts
@@ -578,8 +578,8 @@ describe("Context Management", () => {
const mockSummarizeResponse: condenseModule.SummarizeResponse = {
messages: [
{ role: "user", content: "First message" },
- { role: "assistant", content: mockSummary, isSummary: true },
- { role: "user", content: "Last message" },
+ { role: "user", content: mockSummary, isSummary: true },
+ { role: "assistant", content: "Last message" },
],
summary: mockSummary,
cost: mockCost,
@@ -612,17 +612,13 @@ describe("Context Management", () => {
})
// Verify summarizeConversation was called with the right parameters
- expect(summarizeSpy).toHaveBeenCalledWith(
- messagesWithSmallContent,
- mockApiHandler,
- "System prompt",
+ expect(summarizeSpy).toHaveBeenCalledWith({
+ messages: messagesWithSmallContent,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
taskId,
- 70001,
- true,
- undefined, // customCondensingPrompt
- undefined, // condensingApiHandler
- undefined, // useNativeTools
- )
+ isAutomaticTrigger: true,
+ })
// Verify the result contains the summary information
expect(result).toMatchObject({
@@ -752,8 +748,8 @@ describe("Context Management", () => {
const mockSummarizeResponse: condenseModule.SummarizeResponse = {
messages: [
{ role: "user", content: "First message" },
- { role: "assistant", content: mockSummary, isSummary: true },
- { role: "user", content: "Last message" },
+ { role: "user", content: mockSummary, isSummary: true },
+ { role: "assistant", content: "Last message" },
],
summary: mockSummary,
cost: mockCost,
@@ -788,17 +784,13 @@ describe("Context Management", () => {
})
// Verify summarizeConversation was called with the right parameters
- expect(summarizeSpy).toHaveBeenCalledWith(
- messagesWithSmallContent,
- mockApiHandler,
- "System prompt",
+ expect(summarizeSpy).toHaveBeenCalledWith({
+ messages: messagesWithSmallContent,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
taskId,
- 60000,
- true,
- undefined, // customCondensingPrompt
- undefined, // condensingApiHandler
- undefined, // useNativeTools
- )
+ isAutomaticTrigger: true,
+ })
// Verify the result contains the summary information
expect(result).toMatchObject({
@@ -856,6 +848,215 @@ describe("Context Management", () => {
})
})
+ /**
+ * Tests for filesReadByRoo being passed to summarizeConversation
+ */
+ describe("filesReadByRoo parameters", () => {
+ const createModelInfo = (contextWindow: number, maxTokens?: number): ModelInfo => ({
+ contextWindow,
+ supportsPromptCache: true,
+ maxTokens,
+ })
+
+ const messages: ApiMessage[] = [
+ { role: "user", content: "First message" },
+ { role: "assistant", content: "Second message" },
+ { role: "user", content: "Third message" },
+ { role: "assistant", content: "Fourth message" },
+ { role: "user", content: "Fifth message" },
+ ]
+
+ it("should pass filesReadByRoo, cwd, and rooIgnoreController to summarizeConversation when provided", async () => {
+ // Mock the summarizeConversation function
+ const mockSummary = "Summary with folded context"
+ const mockCost = 0.05
+ const mockSummarizeResponse: condenseModule.SummarizeResponse = {
+ messages: [
+ { role: "user", content: "First message" },
+ { role: "assistant", content: mockSummary, isSummary: true },
+ { role: "user", content: "Last message" },
+ ],
+ summary: mockSummary,
+ cost: mockCost,
+ newContextTokens: 100,
+ }
+
+ const summarizeSpy = vi
+ .spyOn(condenseModule, "summarizeConversation")
+ .mockResolvedValue(mockSummarizeResponse)
+
+ const modelInfo = createModelInfo(100000, 30000)
+ const totalTokens = 70001 // Above threshold
+ const messagesWithSmallContent = [
+ ...messages.slice(0, -1),
+ { ...messages[messages.length - 1], content: "" },
+ ]
+
+ const filesReadByRoo = ["src/test.ts", "src/utils.ts"]
+ const cwd = "/test/project"
+ const mockRooIgnoreController = {
+ filterPaths: vi.fn(),
+ } as unknown as import("../../ignore/RooIgnoreController").RooIgnoreController
+
+ const result = await manageContext({
+ messages: messagesWithSmallContent,
+ totalTokens,
+ contextWindow: modelInfo.contextWindow,
+ maxTokens: modelInfo.maxTokens,
+ apiHandler: mockApiHandler,
+ autoCondenseContext: true,
+ autoCondenseContextPercent: 100,
+ systemPrompt: "System prompt",
+ taskId,
+ profileThresholds: {},
+ currentProfileId: "default",
+ filesReadByRoo,
+ cwd,
+ rooIgnoreController: mockRooIgnoreController,
+ })
+
+ // Verify summarizeConversation was called with filesReadByRoo, cwd, and rooIgnoreController
+ expect(summarizeSpy).toHaveBeenCalledWith({
+ messages: messagesWithSmallContent,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: true,
+ filesReadByRoo,
+ cwd,
+ rooIgnoreController: mockRooIgnoreController,
+ })
+
+ // Verify the result contains the summary information
+ expect(result).toMatchObject({
+ messages: mockSummarizeResponse.messages,
+ summary: mockSummary,
+ cost: mockCost,
+ prevContextTokens: totalTokens,
+ })
+
+ // Clean up
+ summarizeSpy.mockRestore()
+ })
+
+ it("should pass undefined filesReadByRoo parameters when not provided", async () => {
+ // Mock the summarizeConversation function
+ const mockSummary = "Summary without folded context"
+ const mockCost = 0.03
+ const mockSummarizeResponse: condenseModule.SummarizeResponse = {
+ messages: [
+ { role: "user", content: "First message" },
+ { role: "assistant", content: mockSummary, isSummary: true },
+ { role: "user", content: "Last message" },
+ ],
+ summary: mockSummary,
+ cost: mockCost,
+ newContextTokens: 80,
+ }
+
+ const summarizeSpy = vi
+ .spyOn(condenseModule, "summarizeConversation")
+ .mockResolvedValue(mockSummarizeResponse)
+
+ const modelInfo = createModelInfo(100000, 30000)
+ const totalTokens = 70001 // Above threshold
+ const messagesWithSmallContent = [
+ ...messages.slice(0, -1),
+ { ...messages[messages.length - 1], content: "" },
+ ]
+
+ const result = await manageContext({
+ messages: messagesWithSmallContent,
+ totalTokens,
+ contextWindow: modelInfo.contextWindow,
+ maxTokens: modelInfo.maxTokens,
+ apiHandler: mockApiHandler,
+ autoCondenseContext: true,
+ autoCondenseContextPercent: 100,
+ systemPrompt: "System prompt",
+ taskId,
+ profileThresholds: {},
+ currentProfileId: "default",
+ // filesReadByRoo, cwd, rooIgnoreController are NOT provided
+ })
+
+ // Verify summarizeConversation was called with undefined parameters
+ expect(summarizeSpy).toHaveBeenCalledWith({
+ messages: messagesWithSmallContent,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: true,
+ })
+
+ // Verify the result
+ expect(result).toMatchObject({
+ summary: mockSummary,
+ cost: mockCost,
+ })
+
+ // Clean up
+ summarizeSpy.mockRestore()
+ })
+
+ it("should pass empty array filesReadByRoo when provided as empty", async () => {
+ // Mock the summarizeConversation function
+ const mockSummary = "Summary with empty file list"
+ const mockCost = 0.04
+ const mockSummarizeResponse: condenseModule.SummarizeResponse = {
+ messages: [
+ { role: "user", content: "First message" },
+ { role: "assistant", content: mockSummary, isSummary: true },
+ { role: "user", content: "Last message" },
+ ],
+ summary: mockSummary,
+ cost: mockCost,
+ newContextTokens: 90,
+ }
+
+ const summarizeSpy = vi
+ .spyOn(condenseModule, "summarizeConversation")
+ .mockResolvedValue(mockSummarizeResponse)
+
+ const modelInfo = createModelInfo(100000, 30000)
+ const totalTokens = 70001 // Above threshold
+ const messagesWithSmallContent = [
+ ...messages.slice(0, -1),
+ { ...messages[messages.length - 1], content: "" },
+ ]
+
+ const result = await manageContext({
+ messages: messagesWithSmallContent,
+ totalTokens,
+ contextWindow: modelInfo.contextWindow,
+ maxTokens: modelInfo.maxTokens,
+ apiHandler: mockApiHandler,
+ autoCondenseContext: true,
+ autoCondenseContextPercent: 100,
+ systemPrompt: "System prompt",
+ taskId,
+ profileThresholds: {},
+ currentProfileId: "default",
+ filesReadByRoo: [], // Empty array
+ cwd: "/test/project",
+ })
+
+ // Verify summarizeConversation was called with empty array
+ expect(summarizeSpy).toHaveBeenCalledWith({
+ messages: messagesWithSmallContent,
+ apiHandler: mockApiHandler,
+ systemPrompt: "System prompt",
+ taskId,
+ isAutomaticTrigger: true,
+ filesReadByRoo: [],
+ cwd: "/test/project",
+ })
+
+ // Clean up
+ summarizeSpy.mockRestore()
+ })
+ })
+
/**
* Tests for profile-specific thresholds functionality
*/
@@ -901,8 +1102,8 @@ describe("Context Management", () => {
const mockSummarizeResponse: condenseModule.SummarizeResponse = {
messages: [
{ role: "user", content: "First message" },
- { role: "assistant", content: mockSummary, isSummary: true },
- { role: "user", content: "Last message" },
+ { role: "user", content: mockSummary, isSummary: true },
+ { role: "assistant", content: "Last message" },
],
summary: mockSummary,
cost: mockCost,
@@ -967,8 +1168,8 @@ describe("Context Management", () => {
const mockSummarizeResponse: condenseModule.SummarizeResponse = {
messages: [
{ role: "user", content: "First message" },
- { role: "assistant", content: mockSummary, isSummary: true },
- { role: "user", content: "Last message" },
+ { role: "user", content: mockSummary, isSummary: true },
+ { role: "assistant", content: "Last message" },
],
summary: mockSummary,
cost: mockCost,
diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts
index a94a53c9d5..243d7bd797 100644
--- a/src/core/context-management/index.ts
+++ b/src/core/context-management/index.ts
@@ -3,10 +3,11 @@ import crypto from "crypto"
import { TelemetryService } from "@roo-code/telemetry"
-import { ApiHandler } from "../../api"
+import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api"
import { MAX_CONDENSE_THRESHOLD, MIN_CONDENSE_THRESHOLD, summarizeConversation, SummarizeResponse } from "../condense"
import { ApiMessage } from "../task-persistence/apiMessages"
import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types"
+import { RooIgnoreController } from "../ignore/RooIgnoreController"
/**
* Context Management
@@ -216,10 +217,18 @@ export type ContextManagementOptions = {
systemPrompt: string
taskId: string
customCondensingPrompt?: string
- condensingApiHandler?: ApiHandler
profileThresholds: Record
currentProfileId: string
- useNativeTools?: boolean
+ /** Optional metadata to pass through to the condensing API call (tools, taskId, etc.) */
+ metadata?: ApiHandlerCreateMessageMetadata
+ /** Optional environment details string to include in the condensed summary */
+ environmentDetails?: string
+ /** Optional array of file paths read by Roo during the task (will be folded via tree-sitter) */
+ filesReadByRoo?: string[]
+ /** Optional current working directory for resolving file paths (required if filesReadByRoo is provided) */
+ cwd?: string
+ /** Optional controller for file access validation */
+ rooIgnoreController?: RooIgnoreController
}
export type ContextManagementResult = SummarizeResponse & {
@@ -246,12 +255,16 @@ export async function manageContext({
systemPrompt,
taskId,
customCondensingPrompt,
- condensingApiHandler,
profileThresholds,
currentProfileId,
- useNativeTools,
+ metadata,
+ environmentDetails,
+ filesReadByRoo,
+ cwd,
+ rooIgnoreController,
}: ContextManagementOptions): Promise {
let error: string | undefined
+ let errorDetails: string | undefined
let cost = 0
// Calculate the maximum tokens reserved for response
const reservedTokens = maxTokens || ANTHROPIC_DEFAULT_MAX_TOKENS
@@ -294,19 +307,22 @@ export async function manageContext({
const contextPercent = (100 * prevContextTokens) / contextWindow
if (contextPercent >= effectiveThreshold || prevContextTokens > allowedTokens) {
// Attempt to intelligently condense the context
- const result = await summarizeConversation(
+ const result = await summarizeConversation({
messages,
apiHandler,
systemPrompt,
taskId,
- prevContextTokens,
- true, // automatic trigger
+ isAutomaticTrigger: true,
customCondensingPrompt,
- condensingApiHandler,
- useNativeTools,
- )
+ metadata,
+ environmentDetails,
+ filesReadByRoo,
+ cwd,
+ rooIgnoreController,
+ })
if (result.error) {
error = result.error
+ errorDetails = result.errorDetails
cost = result.cost
} else {
return { ...result, prevContextTokens }
@@ -349,11 +365,12 @@ export async function manageContext({
summary: "",
cost,
error,
+ errorDetails,
truncationId: truncationResult.truncationId,
messagesRemoved: truncationResult.messagesRemoved,
newContextTokensAfterTruncation,
}
}
// No truncation or condensation needed
- return { messages, summary: "", cost, prevContextTokens, error }
+ return { messages, summary: "", cost, prevContextTokens, error, errorDetails }
}
diff --git a/src/core/context-tracking/FileContextTracker.ts b/src/core/context-tracking/FileContextTracker.ts
index 5741b62cfc..4c5640afdf 100644
--- a/src/core/context-tracking/FileContextTracker.ts
+++ b/src/core/context-tracking/FileContextTracker.ts
@@ -206,6 +206,59 @@ export class FileContextTracker {
return files
}
+ /**
+ * Gets a list of unique file paths that Roo has read during this task.
+ * Files are sorted by most recently read first, so if there's a character
+ * budget during folded context generation, the most relevant (recent) files
+ * are prioritized.
+ *
+ * @param sinceTimestamp - Optional timestamp to filter files read after this time
+ * @returns Array of unique file paths that have been read, most recent first
+ */
+ async getFilesReadByRoo(sinceTimestamp?: number): Promise {
+ try {
+ const metadata = await this.getTaskMetadata(this.taskId)
+
+ const readEntries = metadata.files_in_context.filter((entry) => {
+ // Only include files that were read by Roo (not user edits)
+ const isReadByRoo = entry.record_source === "read_tool" || entry.record_source === "file_mentioned"
+ if (!isReadByRoo) {
+ return false
+ }
+
+ // If sinceTimestamp is provided, only include files read after that time
+ if (sinceTimestamp && entry.roo_read_date) {
+ return entry.roo_read_date >= sinceTimestamp
+ }
+
+ return true
+ })
+
+ // Sort by roo_read_date descending (most recent first)
+ // Entries without a date go to the end
+ readEntries.sort((a, b) => {
+ const dateA = a.roo_read_date ?? 0
+ const dateB = b.roo_read_date ?? 0
+ return dateB - dateA
+ })
+
+ // Deduplicate while preserving order (first occurrence = most recent read)
+ const seen = new Set()
+ const uniquePaths: string[] = []
+ for (const entry of readEntries) {
+ if (!seen.has(entry.path)) {
+ seen.add(entry.path)
+ uniquePaths.push(entry.path)
+ }
+ }
+
+ return uniquePaths
+ } catch (error) {
+ console.error("Failed to get files read by Roo:", error)
+ return []
+ }
+ }
+
getAndClearCheckpointPossibleFile(): string[] {
const files = Array.from(this.checkpointPossibleFiles)
this.checkpointPossibleFiles.clear()
diff --git a/src/core/diff/strategies/__tests__/multi-file-search-replace-8char.spec.ts b/src/core/diff/strategies/__tests__/multi-file-search-replace-8char.spec.ts
deleted file mode 100644
index 4d5d29ca39..0000000000
--- a/src/core/diff/strategies/__tests__/multi-file-search-replace-8char.spec.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import { describe, it, expect } from "vitest"
-import { MultiFileSearchReplaceDiffStrategy } from "../multi-file-search-replace"
-
-describe("MultiFileSearchReplaceDiffStrategy - 8-character marker support", () => {
- it("should handle 8 '<' characters in SEARCH marker (PR #9456 use case)", async () => {
- const strategy = new MultiFileSearchReplaceDiffStrategy()
- const originalContent = "line 1\nline 2\nline 3"
-
- const diff = `<<<<<<<< SEARCH
-:start_line:1
--------
-line 1
-=======
-modified line 1
->>>>>>>> REPLACE`
-
- const result = await strategy.applyDiff(originalContent, diff)
-
- expect(result.success).toBe(true)
- if (result.success) {
- expect(result.content).toBe("modified line 1\nline 2\nline 3")
- }
- })
-
- it("should handle 7 '<' characters in SEARCH marker (standard)", async () => {
- const strategy = new MultiFileSearchReplaceDiffStrategy()
- const originalContent = "line 1\nline 2\nline 3"
-
- const diff = `<<<<<<< SEARCH
-:start_line:1
--------
-line 1
-=======
-modified line 1
->>>>>>> REPLACE`
-
- const result = await strategy.applyDiff(originalContent, diff)
-
- expect(result.success).toBe(true)
- if (result.success) {
- expect(result.content).toBe("modified line 1\nline 2\nline 3")
- }
- })
-
- it("should handle 8 '>' characters in REPLACE marker", async () => {
- const strategy = new MultiFileSearchReplaceDiffStrategy()
- const originalContent = "line 1\nline 2\nline 3"
-
- const diff = `<<<<<<< SEARCH
-:start_line:2
--------
-line 2
-=======
-modified line 2
->>>>>>>> REPLACE`
-
- const result = await strategy.applyDiff(originalContent, diff)
-
- expect(result.success).toBe(true)
- if (result.success) {
- expect(result.content).toBe("line 1\nmodified line 2\nline 3")
- }
- })
-
- it("should handle optional '<' at end of REPLACE marker", async () => {
- const strategy = new MultiFileSearchReplaceDiffStrategy()
- const originalContent = "line 1\nline 2\nline 3"
-
- const diff = `<<<<<<< SEARCH
-:start_line:3
--------
-line 3
-=======
-modified line 3
->>>>>>> REPLACE<`
-
- const result = await strategy.applyDiff(originalContent, diff)
-
- expect(result.success).toBe(true)
- if (result.success) {
- expect(result.content).toBe("line 1\nline 2\nmodified line 3")
- }
- })
-
- it("should handle mixed 7 and 8 character markers in same diff", async () => {
- const strategy = new MultiFileSearchReplaceDiffStrategy()
- const originalContent = "line 1\nline 2\nline 3"
-
- const diff = `<<<<<<<< SEARCH
-:start_line:1
--------
-line 1
-=======
-modified line 1
->>>>>>> REPLACE
-
-<<<<<<< SEARCH
-:start_line:3
--------
-line 3
-=======
-modified line 3
->>>>>>>> REPLACE`
-
- const result = await strategy.applyDiff(originalContent, diff)
-
- expect(result.success).toBe(true)
- if (result.success) {
- expect(result.content).toBe("modified line 1\nline 2\nmodified line 3")
- }
- })
-
- it("should reject markers with too many characters (9+)", async () => {
- const strategy = new MultiFileSearchReplaceDiffStrategy()
- const originalContent = "line 1\nline 2\nline 3"
-
- const diff = `<<<<<<<<< SEARCH
-:start_line:1
--------
-line 1
-=======
-modified line 1
->>>>>>> REPLACE`
-
- const result = await strategy.applyDiff(originalContent, diff)
-
- expect(result.success).toBe(false)
- if (!result.success) {
- expect(result.error).toContain("Diff block is malformed")
- }
- })
-
- it("should reject markers with too few characters (6-)", async () => {
- const strategy = new MultiFileSearchReplaceDiffStrategy()
- const originalContent = "line 1\nline 2\nline 3"
-
- const diff = `<<<<<< SEARCH
-:start_line:1
--------
-line 1
-=======
-modified line 1
->>>>>>> REPLACE`
-
- const result = await strategy.applyDiff(originalContent, diff)
-
- expect(result.success).toBe(false)
- if (!result.success) {
- expect(result.error).toContain("Diff block is malformed")
- }
- })
-
- it("should handle validation with 8 character markers", () => {
- const strategy = new MultiFileSearchReplaceDiffStrategy()
-
- const diff = `<<<<<<<< SEARCH
-:start_line:1
--------
-content
-=======
-new content
->>>>>>>> REPLACE`
-
- const result = strategy["validateMarkerSequencing"](diff)
-
- expect(result.success).toBe(true)
- })
-
- it("should detect merge conflict with 8 character prefix", () => {
- const strategy = new MultiFileSearchReplaceDiffStrategy()
-
- const diff = `<<<<<<<< SEARCH
-:start_line:1
--------
-content
-<<<<<<<< HEAD
-conflict content
-=======
-new content
->>>>>>>> REPLACE`
-
- const result = strategy["validateMarkerSequencing"](diff)
-
- expect(result.success).toBe(false)
- if (!result.success) {
- expect(result.error).toContain("merge conflict")
- }
- })
-})
diff --git a/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts b/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts
index b25286f5fa..f06f3f406f 100644
--- a/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts
+++ b/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts
@@ -1041,29 +1041,6 @@ function sum(a, b) {
})
})
- describe("getToolDescription", () => {
- let strategy: MultiSearchReplaceDiffStrategy
-
- beforeEach(() => {
- strategy = new MultiSearchReplaceDiffStrategy()
- })
-
- it("should include the current workspace directory", async () => {
- const cwd = "/test/dir"
- const description = await strategy.getToolDescription({ cwd })
- expect(description).toContain(`relative to the current workspace directory ${cwd}`)
- })
-
- it("should include required format elements", async () => {
- const description = await strategy.getToolDescription({ cwd: "/test" })
- expect(description).toContain("<<<<<<< SEARCH")
- expect(description).toContain("=======")
- expect(description).toContain(">>>>>>> REPLACE")
- expect(description).toContain("")
- expect(description).toContain(" ")
- })
- })
-
describe("line marker validation in REPLACE sections", () => {
let strategy: MultiSearchReplaceDiffStrategy
diff --git a/src/core/diff/strategies/multi-file-search-replace.ts b/src/core/diff/strategies/multi-file-search-replace.ts
deleted file mode 100644
index 1236a98fbb..0000000000
--- a/src/core/diff/strategies/multi-file-search-replace.ts
+++ /dev/null
@@ -1,741 +0,0 @@
-import { distance } from "fastest-levenshtein"
-import { ToolProgressStatus } from "@roo-code/types"
-
-import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
-import { ToolUse, DiffStrategy, DiffResult } from "../../../shared/tools"
-import { normalizeString } from "../../../utils/text-normalization"
-
-const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches
-
-function getSimilarity(original: string, search: string): number {
- // Empty searches are no longer supported
- if (search === "") {
- return 0
- }
-
- // Use the normalizeString utility to handle smart quotes and other special characters
- const normalizedOriginal = normalizeString(original)
- const normalizedSearch = normalizeString(search)
-
- if (normalizedOriginal === normalizedSearch) {
- return 1
- }
-
- // Calculate Levenshtein distance using fastest-levenshtein's distance function
- const dist = distance(normalizedOriginal, normalizedSearch)
-
- // Calculate similarity ratio (0 to 1, where 1 is an exact match)
- const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length)
- return 1 - dist / maxLength
-}
-
-/**
- * Performs a "middle-out" search of `lines` (between [startIndex, endIndex]) to find
- * the slice that is most similar to `searchChunk`. Returns the best score, index, and matched text.
- */
-function fuzzySearch(lines: string[], searchChunk: string, startIndex: number, endIndex: number) {
- let bestScore = 0
- let bestMatchIndex = -1
- let bestMatchContent = ""
-
- const searchLen = searchChunk.split(/\r?\n/).length
-
- // Middle-out from the midpoint
- const midPoint = Math.floor((startIndex + endIndex) / 2)
- let leftIndex = midPoint
- let rightIndex = midPoint + 1
-
- while (leftIndex >= startIndex || rightIndex <= endIndex - searchLen) {
- if (leftIndex >= startIndex) {
- const originalChunk = lines.slice(leftIndex, leftIndex + searchLen).join("\n")
- const similarity = getSimilarity(originalChunk, searchChunk)
-
- if (similarity > bestScore) {
- bestScore = similarity
- bestMatchIndex = leftIndex
- bestMatchContent = originalChunk
- }
- leftIndex--
- }
-
- if (rightIndex <= endIndex - searchLen) {
- const originalChunk = lines.slice(rightIndex, rightIndex + searchLen).join("\n")
- const similarity = getSimilarity(originalChunk, searchChunk)
-
- if (similarity > bestScore) {
- bestScore = similarity
- bestMatchIndex = rightIndex
- bestMatchContent = originalChunk
- }
- rightIndex++
- }
- }
-
- return { bestScore, bestMatchIndex, bestMatchContent }
-}
-
-export class MultiFileSearchReplaceDiffStrategy implements DiffStrategy {
- private fuzzyThreshold: number
- private bufferLines: number
-
- getName(): string {
- return "MultiFileSearchReplace"
- }
-
- constructor(fuzzyThreshold?: number, bufferLines?: number) {
- // Use provided threshold or default to exact matching (1.0)
- // Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9)
- // so we use it directly here
- this.fuzzyThreshold = fuzzyThreshold ?? 1.0
- this.bufferLines = bufferLines ?? BUFFER_LINES
- }
-
- getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string {
- return `## apply_diff
-
-Description: Request to apply PRECISE, TARGETED modifications to one or more files by searching for specific sections of content and replacing them. This tool is for SURGICAL EDITS ONLY - specific changes to existing code. This tool supports both single-file and multi-file operations, allowing you to make changes across multiple files in a single request.
-
-**IMPORTANT: You MUST use multiple files in a single operation whenever possible to maximize efficiency and minimize back-and-forth.**
-
-You can perform multiple distinct search and replace operations within a single \`apply_diff\` call by providing multiple SEARCH/REPLACE blocks in the \`diff\` parameter. This is the preferred way to make several targeted changes efficiently.
-
-The SEARCH section must exactly match existing content including whitespace and indentation.
-If you're not confident in the exact content to search for, use the read_file tool first to get the exact content.
-When applying the diffs, be extra careful to remember to change any closing brackets or other syntax that may be affected by the diff farther down in the file.
-ALWAYS make as many changes in a single 'apply_diff' request as possible using multiple SEARCH/REPLACE blocks
-
-Parameters:
-- args: Contains one or more file elements, where each file contains:
- - path: (required) The path of the file to modify (relative to the current workspace directory ${args.cwd})
- - diff: (required) One or more diff elements containing:
- - content: (required) The search/replace block defining the changes.
- - start_line: (required) The line number of original content where the search block starts.
-
-Diff format:
-\`\`\`
-<<<<<<< SEARCH
-:start_line: (required) The line number of original content where the search block starts.
--------
-[exact content to find including whitespace]
-=======
-[new content to replace with]
->>>>>>> REPLACE
-\`\`\`
-
-Example:
-
-Original file:
-\`\`\`
-1 | def calculate_total(items):
-2 | total = 0
-3 | for item in items:
-4 | total += item
-5 | return total
-\`\`\`
-
-Search/Replace content:
-
-
-
- eg.file.py
-
- >>>>>> REPLACE
-]]>
-
-
-
-
-
-Search/Replace content with multi edits across multiple files:
-
-
-
- eg.file.py
-
- >>>>>> REPLACE
-]]>
-
-
- >>>>>> REPLACE
-]]>
-
-
-
- eg.file2.py
-
- >>>>>> REPLACE
-]]>
-
-
-
-
-
-
-Usage:
-
-
-
- File path here
-
-
-Your search/replace content here
-You can use multi search/replace block in one diff block, but make sure to include the line numbers for each block.
-Only use a single line of '=======' between search and replacement content, because multiple '=======' will corrupt the file.
-
- 1
-
-
-
- Another file path
-
-
-Another search/replace content here
-You can apply changes to multiple files in a single request.
-Each file requires its own path, start_line, and diff elements.
-
- 5
-
-
-
- `
- }
-
- private unescapeMarkers(content: string): string {
- return content
- .replace(/^\\<<<<<<>>>>>>/gm, ">>>>>>>")
- .replace(/^\\-------/gm, "-------")
- .replace(/^\\:end_line:/gm, ":end_line:")
- .replace(/^\\:start_line:/gm, ":start_line:")
- }
-
- private validateMarkerSequencing(diffContent: string): { success: boolean; error?: string } {
- enum State {
- START,
- AFTER_SEARCH,
- AFTER_SEPARATOR,
- }
-
- const state = { current: State.START, line: 0 }
-
- // Pattern allows optional extra '<' or '>' for SEARCH to handle AI-generated diffs
- // (e.g., Sonnet 4 sometimes adds extra markers)
- // Using explicit alternation instead of quantifiers to avoid regex backtracking
- const SEARCH_PATTERN = /^(?:<<<<<<< |<<<<<<<< )SEARCH>?$/
- const SEARCH = "<<<<<<< SEARCH" // Simplified for display
- const SEP = "======="
- // Pattern allows optional extra '>' or '<' for REPLACE
- const REPLACE_PATTERN = /^(?:>>>>>>> |>>>>>>>> )REPLACE$/
- const REPLACE = ">>>>>>> REPLACE" // Simplified for display
- const SEARCH_PREFIX_PATTERN = /^(?:<<<<<<< |<<<<<<<< )/
- const REPLACE_PREFIX_PATTERN = /^(?:>>>>>>> |>>>>>>>> )/
-
- const reportMergeConflictError = (found: string, _expected: string) => ({
- success: false,
- error:
- `ERROR: Special marker '${found}' found in your diff content at line ${state.line}:\n` +
- "\n" +
- `When removing merge conflict markers like '${found}' from files, you MUST escape them\n` +
- "in your SEARCH section by prepending a backslash (\\) at the beginning of the line:\n" +
- "\n" +
- "CORRECT FORMAT:\n\n" +
- "<<<<<<< SEARCH\n" +
- "content before\n" +
- `\\${found} <-- Note the backslash here in this example\n` +
- "content after\n" +
- "=======\n" +
- "replacement content\n" +
- ">>>>>>> REPLACE\n" +
- "\n" +
- "Without escaping, the system confuses your content with diff syntax markers.\n" +
- "You may use multiple diff blocks in a single diff request, but ANY of ONLY the following separators that occur within SEARCH or REPLACE content must be escaped, as follows:\n" +
- `\\${SEARCH}\n` +
- `\\${SEP}\n` +
- `\\${REPLACE}\n`,
- })
-
- const reportInvalidDiffError = (found: string, expected: string) => ({
- success: false,
- error:
- `ERROR: Diff block is malformed: marker '${found}' found in your diff content at line ${state.line}. Expected: ${expected}\n` +
- "\n" +
- "CORRECT FORMAT:\n\n" +
- "<<<<<<< SEARCH\n" +
- ":start_line: (required) The line number of original content where the search block starts.\n" +
- "-------\n" +
- "[exact content to find including whitespace]\n" +
- "=======\n" +
- "[new content to replace with]\n" +
- ">>>>>>> REPLACE\n",
- })
-
- const reportLineMarkerInReplaceError = (marker: string) => ({
- success: false,
- error:
- `ERROR: Invalid line marker '${marker}' found in REPLACE section at line ${state.line}\n` +
- "\n" +
- "Line markers (:start_line: and :end_line:) are only allowed in SEARCH sections.\n" +
- "\n" +
- "CORRECT FORMAT:\n" +
- "<<<<<<< SEARCH\n" +
- ":start_line:5\n" +
- "content to find\n" +
- "=======\n" +
- "replacement content\n" +
- ">>>>>>> REPLACE\n" +
- "\n" +
- "INCORRECT FORMAT:\n" +
- "<<<<<<< SEARCH\n" +
- "content to find\n" +
- "=======\n" +
- ":start_line:5 <-- Invalid location\n" +
- "replacement content\n" +
- ">>>>>>> REPLACE\n",
- })
-
- const lines = diffContent.split("\n")
- const searchCount = lines.filter((l) => SEARCH_PATTERN.test(l.trim())).length
- const sepCount = lines.filter((l) => l.trim() === SEP).length
- const replaceCount = lines.filter((l) => REPLACE_PATTERN.test(l.trim())).length
-
- const likelyBadStructure = searchCount !== replaceCount || sepCount < searchCount
-
- for (const line of diffContent.split("\n")) {
- state.line++
- const marker = line.trim()
-
- // Check for line markers in REPLACE sections (but allow escaped ones)
- if (state.current === State.AFTER_SEPARATOR) {
- if (marker.startsWith(":start_line:") && !line.trim().startsWith("\\:start_line:")) {
- return reportLineMarkerInReplaceError(":start_line:")
- }
- if (marker.startsWith(":end_line:") && !line.trim().startsWith("\\:end_line:")) {
- return reportLineMarkerInReplaceError(":end_line:")
- }
- }
-
- switch (state.current) {
- case State.START:
- if (marker === SEP)
- return likelyBadStructure
- ? reportInvalidDiffError(SEP, SEARCH)
- : reportMergeConflictError(SEP, SEARCH)
- if (REPLACE_PATTERN.test(marker)) return reportInvalidDiffError(REPLACE, SEARCH)
- if (REPLACE_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, SEARCH)
- if (SEARCH_PATTERN.test(marker)) state.current = State.AFTER_SEARCH
- else if (SEARCH_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, SEARCH)
- break
-
- case State.AFTER_SEARCH:
- if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH, SEP)
- if (SEARCH_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, SEARCH)
- if (REPLACE_PATTERN.test(marker)) return reportInvalidDiffError(REPLACE, SEP)
- if (REPLACE_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, SEARCH)
- if (marker === SEP) state.current = State.AFTER_SEPARATOR
- break
-
- case State.AFTER_SEPARATOR:
- if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH, REPLACE)
- if (SEARCH_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, REPLACE)
- if (marker === SEP)
- return likelyBadStructure
- ? reportInvalidDiffError(SEP, REPLACE)
- : reportMergeConflictError(SEP, REPLACE)
- if (REPLACE_PATTERN.test(marker)) state.current = State.START
- else if (REPLACE_PREFIX_PATTERN.test(marker)) return reportMergeConflictError(marker, REPLACE)
- break
- }
- }
-
- return state.current === State.START
- ? { success: true }
- : {
- success: false,
- error: `ERROR: Unexpected end of sequence: Expected '${
- state.current === State.AFTER_SEARCH ? "=======" : ">>>>>>> REPLACE"
- }' was not found.`,
- }
- }
-
- async applyDiff(
- originalContent: string,
- diffContent: string | Array<{ content: string; startLine?: number }>,
- _paramStartLine?: number,
- _paramEndLine?: number,
- ): Promise {
- // Handle array-based input for multi-file support
- if (Array.isArray(diffContent)) {
- // Process each diff item separately and combine results
- let resultContent = originalContent
- const allFailParts: DiffResult[] = []
- let successCount = 0
-
- for (const diffItem of diffContent) {
- const singleResult = await this.applySingleDiff(resultContent, diffItem.content, diffItem.startLine)
-
- if (singleResult.success && singleResult.content) {
- resultContent = singleResult.content
- successCount++
- } else {
- // If singleResult has failParts, push those directly to avoid nesting
- if (singleResult.failParts && singleResult.failParts.length > 0) {
- allFailParts.push(...singleResult.failParts)
- } else {
- // Otherwise push the single result itself
- allFailParts.push(singleResult)
- }
- }
- }
-
- if (successCount === 0) {
- return {
- success: false,
- error: "Failed to apply any diffs",
- failParts: allFailParts,
- }
- }
-
- return {
- success: true,
- content: resultContent,
- failParts: allFailParts.length > 0 ? allFailParts : undefined,
- }
- }
-
- // Handle string-based input (legacy)
- return this.applySingleDiff(originalContent, diffContent, _paramStartLine)
- }
-
- private async applySingleDiff(
- originalContent: string,
- diffContent: string,
- _paramStartLine?: number,
- ): Promise {
- const validseq = this.validateMarkerSequencing(diffContent)
- if (!validseq.success) {
- return {
- success: false,
- error: validseq.error!,
- }
- }
-
- /* Regex parts:
- 1. (?:^|\n) Ensures the first marker starts at the beginning of the file or right after a newline.
- 2. (??\s*\n Matches "<<<<<<< SEARCH" or "<<<<<<< SEARCH>" or "<<<<<<<< SEARCH" (7 or 8 '<' chars) (ignoring any trailing spaces) – the negative lookbehind makes sure it isn't escaped. Uses explicit alternation to avoid backtracking.
- 3. ((?:\:start_line:\s*(\d+)\s*\n))? Optionally matches a ":start_line:" line. The outer capturing group is group 1 and the inner (\d+) is group 2.
- 4. ((?:\:end_line:\s*(\d+)\s*\n))? Optionally matches a ":end_line:" line. Group 3 is the whole match and group 4 is the digits.
- 5. ((?>>>>>> |>>>>>>>> )REPLACE)(?=\n|$) Matches ">>>>>>> REPLACE" or ">>>>>>> REPLACE<" or ">>>>>>>> REPLACE" (7 or 8 '>' chars) on its own line (and requires a following newline or the end of file). Uses explicit alternation to avoid backtracking.
- */
- let matches = [
- ...diffContent.matchAll(
- /(?:^|\n)(??\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?>>>>>> |>>>>>>>> )REPLACE)(?=\n|$)/g,
- ),
- ]
-
- if (matches.length === 0) {
- return {
- success: false,
- error: `Invalid diff format - missing required sections\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n:start_line: start line\\n-------\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include start_line/SEARCH/=======/REPLACE sections with correct markers on new lines`,
- }
- }
-
- // Detect line ending from original content
- const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n"
- let resultLines = originalContent.split(/\r?\n/)
- let delta = 0
- let diffResults: DiffResult[] = []
- let appliedCount = 0
-
- const replacements = matches
- .map((match) => ({
- startLine: _paramStartLine ?? Number(match[2] ?? 0),
- searchContent: match[6],
- replaceContent: match[7],
- }))
- .sort((a, b) => a.startLine - b.startLine)
-
- for (const replacement of replacements) {
- let { searchContent, replaceContent } = replacement
- let startLine = replacement.startLine + (replacement.startLine === 0 ? 0 : delta)
-
- // First unescape any escaped markers in the content
- searchContent = this.unescapeMarkers(searchContent)
- replaceContent = this.unescapeMarkers(replaceContent)
-
- // Strip line numbers from search and replace content if every line starts with a line number
- const hasAllLineNumbers =
- (everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) ||
- (everyLineHasLineNumbers(searchContent) && replaceContent.trim() === "")
-
- if (hasAllLineNumbers && startLine === 0) {
- startLine = parseInt(searchContent.split("\n")[0].split("|")[0])
- }
-
- if (hasAllLineNumbers) {
- searchContent = stripLineNumbers(searchContent)
- replaceContent = stripLineNumbers(replaceContent)
- }
-
- // Validate that search and replace content are not identical
- if (searchContent === replaceContent) {
- diffResults.push({
- success: false,
- error:
- `Search and replace content are identical - no changes would be made\n\n` +
- `Debug Info:\n` +
- `- Search and replace must be different to make changes\n` +
- `- Use read_file to verify the content you want to change`,
- })
- continue
- }
-
- // Split content into lines, handling both \n and \r\n
- let searchLines = searchContent === "" ? [] : searchContent.split(/\r?\n/)
- let replaceLines = replaceContent === "" ? [] : replaceContent.split(/\r?\n/)
-
- // Validate that search content is not empty
- if (searchLines.length === 0) {
- diffResults.push({
- success: false,
- error: `Empty search content is not allowed\n\nDebug Info:\n- Search content cannot be empty\n- For insertions, provide a specific line using :start_line: and include content to search for\n- For example, match a single line to insert before/after it`,
- })
- continue
- }
-
- let endLine = replacement.startLine + searchLines.length - 1
-
- // Initialize search variables
- let matchIndex = -1
- let bestMatchScore = 0
- let bestMatchContent = ""
- let searchChunk = searchLines.join("\n")
-
- // Determine search bounds
- let searchStartIndex = 0
- let searchEndIndex = resultLines.length
-
- // Validate and handle line range if provided
- if (startLine) {
- // Convert to 0-based index
- const exactStartIndex = startLine - 1
- const searchLen = searchLines.length
- const exactEndIndex = exactStartIndex + searchLen - 1
-
- // Try exact match first
- const originalChunk = resultLines.slice(exactStartIndex, exactEndIndex + 1).join("\n")
- const similarity = getSimilarity(originalChunk, searchChunk)
-
- if (similarity >= this.fuzzyThreshold) {
- matchIndex = exactStartIndex
- bestMatchScore = similarity
- bestMatchContent = originalChunk
- } else {
- // Set bounds for buffered search
- searchStartIndex = Math.max(0, startLine - (this.bufferLines + 1))
- searchEndIndex = Math.min(resultLines.length, startLine + searchLines.length + this.bufferLines)
- }
- }
-
- // If no match found yet, try middle-out search within bounds
- if (matchIndex === -1) {
- const {
- bestScore,
- bestMatchIndex,
- bestMatchContent: midContent,
- } = fuzzySearch(resultLines, searchChunk, searchStartIndex, searchEndIndex)
-
- matchIndex = bestMatchIndex
- bestMatchScore = bestScore
- bestMatchContent = midContent
- }
-
- // Try aggressive line number stripping as a fallback if regular matching fails
- if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) {
- // Strip both search and replace content once (simultaneously)
- const aggressiveSearchContent = stripLineNumbers(searchContent, true)
- const aggressiveReplaceContent = stripLineNumbers(replaceContent, true)
- const aggressiveSearchLines = aggressiveSearchContent ? aggressiveSearchContent.split(/\r?\n/) : []
- const aggressiveSearchChunk = aggressiveSearchLines.join("\n")
-
- // Try middle-out search again with aggressive stripped content (respecting the same search bounds)
- const {
- bestScore,
- bestMatchIndex,
- bestMatchContent: aggContent,
- } = fuzzySearch(resultLines, aggressiveSearchChunk, searchStartIndex, searchEndIndex)
-
- if (bestMatchIndex !== -1 && bestScore >= this.fuzzyThreshold) {
- matchIndex = bestMatchIndex
- bestMatchScore = bestScore
- bestMatchContent = aggContent
-
- // Replace the original search/replace with their stripped versions
- searchContent = aggressiveSearchContent
- replaceContent = aggressiveReplaceContent
- searchLines = aggressiveSearchLines
- replaceLines = replaceContent ? replaceContent.split(/\r?\n/) : []
- } else {
- // No match found with either method
- const originalContentSection =
- startLine !== undefined && endLine !== undefined
- ? `\n\nOriginal Content:\n${addLineNumbers(
- resultLines
- .slice(
- Math.max(0, startLine - 1 - this.bufferLines),
- Math.min(resultLines.length, endLine + this.bufferLines),
- )
- .join("\n"),
- Math.max(1, startLine - this.bufferLines),
- )}`
- : `\n\nOriginal Content:\n${addLineNumbers(resultLines.join("\n"))}`
-
- const bestMatchSection = bestMatchContent
- ? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}`
- : `\n\nBest Match Found:\n(no match)`
-
- const lineRange = startLine ? ` at line: ${startLine}` : ""
-
- diffResults.push({
- success: false,
- error: `No sufficiently similar match found${lineRange} (${Math.floor(
- bestMatchScore * 100,
- )}% similar, needs ${Math.floor(
- this.fuzzyThreshold * 100,
- )}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(
- bestMatchScore * 100,
- )}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${
- startLine ? `starting at line ${startLine}` : "start to end"
- }\n- Tried both standard and aggressive line number stripping\n- Tip: Use the read_file tool to get the latest content of the file before attempting to use the apply_diff tool again, as the file content may have changed\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`,
- })
- continue
- }
- }
-
- // Get the matched lines from the original content
- const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length)
-
- // Get the exact indentation (preserving tabs/spaces) of each line
- const originalIndents = matchedLines.map((line) => {
- const match = line.match(/^[\t ]*/)
- return match ? match[0] : ""
- })
-
- // Get the exact indentation of each line in the search block
- const searchIndents = searchLines.map((line) => {
- const match = line.match(/^[\t ]*/)
- return match ? match[0] : ""
- })
-
- // Apply the replacement while preserving exact indentation
- const indentedReplaceLines = replaceLines.map((line) => {
- // Get the matched line's exact indentation
- const matchedIndent = originalIndents[0] || ""
-
- // Get the current line's indentation relative to the search content
- const currentIndentMatch = line.match(/^[\t ]*/)
- const currentIndent = currentIndentMatch ? currentIndentMatch[0] : ""
- const searchBaseIndent = searchIndents[0] || ""
-
- // Calculate the relative indentation level
- const searchBaseLevel = searchBaseIndent.length
- const currentLevel = currentIndent.length
- const relativeLevel = currentLevel - searchBaseLevel
-
- // If relative level is negative, remove indentation from matched indent
- // If positive, add to matched indent
- const finalIndent =
- relativeLevel < 0
- ? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel))
- : matchedIndent + currentIndent.slice(searchBaseLevel)
-
- return finalIndent + line.trim()
- })
-
- // Construct the final content
- const beforeMatch = resultLines.slice(0, matchIndex)
- const afterMatch = resultLines.slice(matchIndex + searchLines.length)
- resultLines = [...beforeMatch, ...indentedReplaceLines, ...afterMatch]
-
- delta = delta - matchedLines.length + replaceLines.length
- appliedCount++
- }
-
- const finalContent = resultLines.join(lineEnding)
-
- if (appliedCount === 0) {
- return {
- success: false,
- failParts: diffResults,
- }
- }
-
- return {
- success: true,
- content: finalContent,
- failParts: diffResults,
- }
- }
-
- getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus {
- const diffContent = toolUse.params.diff
- if (diffContent) {
- const icon = "diff-multiple"
-
- if (toolUse.partial) {
- if (Math.floor(diffContent.length / 10) % 10 === 0) {
- const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length
- return { icon, text: `${searchBlockCount}` }
- }
- } else if (result) {
- const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length
- if (result.failParts?.length) {
- return {
- icon,
- text: `${searchBlockCount - result.failParts.length}/${searchBlockCount}`,
- }
- } else {
- return { icon, text: `${searchBlockCount}` }
- }
- }
- }
-
- return {}
- }
-}
diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts
index a6a9913203..f43bbee0dc 100644
--- a/src/core/diff/strategies/multi-search-replace.ts
+++ b/src/core/diff/strategies/multi-search-replace.ts
@@ -1,5 +1,3 @@
-/* eslint-disable no-irregular-whitespace */
-
import { distance } from "fastest-levenshtein"
import { ToolProgressStatus } from "@roo-code/types"
@@ -90,96 +88,6 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
this.bufferLines = bufferLines ?? BUFFER_LINES
}
- getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string {
- return `## apply_diff
-Description: Request to apply PRECISE, TARGETED modifications to an existing file by searching for specific sections of content and replacing them. This tool is for SURGICAL EDITS ONLY - specific changes to existing code.
-You can perform multiple distinct search and replace operations within a single \`apply_diff\` call by providing multiple SEARCH/REPLACE blocks in the \`diff\` parameter. This is the preferred way to make several targeted changes efficiently.
-The SEARCH section must exactly match existing content including whitespace and indentation.
-If you're not confident in the exact content to search for, use the read_file tool first to get the exact content.
-When applying the diffs, be extra careful to remember to change any closing brackets or other syntax that may be affected by the diff farther down in the file.
-ALWAYS make as many changes in a single 'apply_diff' request as possible using multiple SEARCH/REPLACE blocks
-
-Parameters:
-- path: (required) The path of the file to modify (relative to the current workspace directory ${args.cwd})
-- diff: (required) The search/replace block defining the changes.
-
-Diff format:
-\`\`\`
-<<<<<<< SEARCH
-:start_line: (required) The line number of original content where the search block starts.
--------
-[exact content to find including whitespace]
-=======
-[new content to replace with]
->>>>>>> REPLACE
-
-\`\`\`
-
-
-Example:
-
-Original file:
-\`\`\`
-1 | def calculate_total(items):
-2 | total = 0
-3 | for item in items:
-4 | total += item
-5 | return total
-\`\`\`
-
-Search/Replace content:
-\`\`\`
-<<<<<<< SEARCH
-:start_line:1
--------
-def calculate_total(items):
- total = 0
- for item in items:
- total += item
- return total
-=======
-def calculate_total(items):
- """Calculate total with 10% markup"""
- return sum(item * 1.1 for item in items)
->>>>>>> REPLACE
-
-\`\`\`
-
-Search/Replace content with multiple edits:
-\`\`\`
-<<<<<<< SEARCH
-:start_line:1
--------
-def calculate_total(items):
- sum = 0
-=======
-def calculate_sum(items):
- sum = 0
->>>>>>> REPLACE
-
-<<<<<<< SEARCH
-:start_line:4
--------
- total += item
- return total
-=======
- sum += item
- return sum
->>>>>>> REPLACE
-\`\`\`
-
-
-Usage:
-
-File path here
-
-Your search/replace content here
-You can use multi search/replace block in one diff block, but make sure to include the line numbers for each block.
-Only use a single line of '=======' between search and replacement content, because multiple '=======' will corrupt the file.
-
- `
- }
-
private unescapeMarkers(content: string): string {
return content
.replace(/^\\<<<<<<>>>>>> REPLACE)(?=\n|$)
- Matches the final “>>>>>>> REPLACE” marker on its own line (and requires a following newline or the end of file).
+ Matches the final ">>>>>>> REPLACE" marker on its own line (and requires a following newline or the end of file).
*/
let matches = [
diff --git a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts
index 65b447ff16..74e000d36a 100644
--- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts
+++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts
@@ -5,7 +5,6 @@ import delay from "delay"
import type { Mock } from "vitest"
import { getEnvironmentDetails } from "../getEnvironmentDetails"
-import { EXPERIMENT_IDS, experiments } from "../../../shared/experiments"
import { getFullModeDetails } from "../../../shared/modes"
import { isToolAllowedForMode } from "../../tools/validateToolUse"
import { getApiMetrics } from "../../../shared/getApiMetrics"
@@ -43,7 +42,6 @@ vi.mock("execa", () => ({
execa: vi.fn(),
}))
-vi.mock("../../../shared/experiments")
vi.mock("../../../shared/modes")
vi.mock("../../../shared/getApiMetrics")
vi.mock("../../../services/glob/list-files")
@@ -115,7 +113,6 @@ describe("getEnvironmentDetails", () => {
createMessage: vi.fn(),
countTokens: vi.fn(),
} as unknown as ApiHandler,
- diffEnabled: true,
providerRef: {
deref: vi.fn().mockReturnValue(mockProvider),
[Symbol.toStringTag]: "WeakRef",
@@ -322,16 +319,6 @@ describe("getEnvironmentDetails", () => {
expect(mockInactiveTerminal.getCurrentWorkingDirectory).toHaveBeenCalled()
})
- it("should include experiment-specific details when Power Steering is enabled", async () => {
- mockState.experiments = { [EXPERIMENT_IDS.POWER_STEERING]: true }
- ;(experiments.isEnabled as Mock).mockReturnValue(true)
-
- const result = await getEnvironmentDetails(mockCline as Task)
-
- expect(result).toContain("You are a code assistant ")
- expect(result).toContain("Custom instructions ")
- })
-
it("should handle missing provider or state", async () => {
// Mock provider to return null.
mockCline.providerRef!.deref = vi.fn().mockReturnValue(null)
diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts
index ebb6f18e48..db5a0cd088 100644
--- a/src/core/environment/getEnvironmentDetails.ts
+++ b/src/core/environment/getEnvironmentDetails.ts
@@ -6,10 +6,7 @@ import pWaitFor from "p-wait-for"
import delay from "delay"
import type { ExperimentId } from "@roo-code/types"
-import { DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types"
-import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
-import { EXPERIMENT_IDS, experiments as Experiments } from "../../shared/experiments"
import { formatLanguage } from "../../shared/language"
import { defaultModeSlug, getFullModeDetails } from "../../shared/modes"
import { getApiMetrics } from "../../shared/getApiMetrics"
@@ -28,11 +25,7 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
const clineProvider = cline.providerRef.deref()
const state = await clineProvider?.getState()
- const {
- terminalOutputLineLimit = 500,
- terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
- maxWorkspaceFiles = 200,
- } = state ?? {}
+ const { maxWorkspaceFiles = 200 } = state ?? {}
// It could be useful for cline to know if the user went from one or no
// file to another between messages, so we always include this context.
@@ -114,11 +107,7 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
let newOutput = TerminalRegistry.getUnretrievedOutput(busyTerminal.id)
if (newOutput) {
- newOutput = Terminal.compressTerminalOutput(
- newOutput,
- terminalOutputLineLimit,
- terminalOutputCharacterLimit,
- )
+ newOutput = Terminal.compressTerminalOutput(newOutput)
terminalDetails += `\n### New Output\n${newOutput}`
}
}
@@ -146,11 +135,7 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
let output = process.getUnretrievedOutput()
if (output) {
- output = Terminal.compressTerminalOutput(
- output,
- terminalOutputLineLimit,
- terminalOutputCharacterLimit,
- )
+ output = Terminal.compressTerminalOutput(output)
terminalOutputs.push(`Command: \`${process.command}\`\n${output}`)
}
}
@@ -236,26 +221,13 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
language: language ?? formatLanguage(vscode.env.language),
})
- // Use the task's locked tool protocol for consistent environment details.
- // This ensures the model sees the same tool format it was started with,
- // even if user settings have changed. Fall back to resolving fresh if
- // the task hasn't been fully initialized yet (shouldn't happen in practice).
- const modelInfo = cline.api.getModel().info
- const toolProtocol = resolveToolProtocol(state?.apiConfiguration ?? {}, modelInfo, cline.taskToolProtocol)
+ const toolFormat = "native"
details += `\n\n# Current Mode\n`
details += `${currentMode} \n`
details += `${modeDetails.name} \n`
details += `${modelId} \n`
- details += `${toolProtocol} \n`
-
- if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) {
- details += `${modeDetails.roleDefinition} \n`
-
- if (modeDetails.customInstructions) {
- details += `${modeDetails.customInstructions} \n`
- }
- }
+ details += `${toolFormat} \n`
// Add browser session status - Only show when active to prevent cluttering context
const isBrowserActive = cline.browserSession.isSessionActive()
diff --git a/src/core/mentions/__tests__/processUserContentMentions.spec.ts b/src/core/mentions/__tests__/processUserContentMentions.spec.ts
index ec2e08f92a..7732cf279b 100644
--- a/src/core/mentions/__tests__/processUserContentMentions.spec.ts
+++ b/src/core/mentions/__tests__/processUserContentMentions.spec.ts
@@ -26,106 +26,16 @@ 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,
- )
- })
-
- 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,
- )
- })
-
- 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,
- )
- })
- })
-
describe("content processing", () => {
- it("should process text blocks with tags", async () => {
+ it("should process text blocks with tags", async () => {
const userContent = [
{
type: "text" as const,
- text: "Do something ",
+ text: "Do something ",
},
]
@@ -139,35 +49,12 @@ describe("processUserContentMentions", () => {
expect(parseMentions).toHaveBeenCalled()
expect(result.content[0]).toEqual({
type: "text",
- text: "parsed: Do something ",
+ text: "parsed: Do something ",
})
expect(result.mode).toBeUndefined()
})
- it("should process text blocks with tags", async () => {
- const userContent = [
- {
- type: "text" as const,
- text: "Fix this issue ",
- },
- ]
-
- const result = await processUserContentMentions({
- userContent,
- cwd: "/test",
- urlContentFetcher: mockUrlContentFetcher,
- fileContextTracker: mockFileContextTracker,
- })
-
- expect(parseMentions).toHaveBeenCalled()
- expect(result.content[0]).toEqual({
- type: "text",
- text: "parsed: Fix this issue ",
- })
- expect(result.mode).toBeUndefined()
- })
-
- it("should not process text blocks without task or feedback tags", async () => {
+ it("should not process text blocks without user_message tags", async () => {
const userContent = [
{
type: "text" as const,
@@ -192,7 +79,7 @@ describe("processUserContentMentions", () => {
{
type: "tool_result" as const,
tool_use_id: "123",
- content: "Tool feedback ",
+ content: "Tool feedback ",
},
]
@@ -204,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()
})
@@ -220,7 +113,7 @@ describe("processUserContentMentions", () => {
content: [
{
type: "text" as const,
- text: "Array task ",
+ text: "Array task ",
},
{
type: "text" as const,
@@ -244,7 +137,7 @@ describe("processUserContentMentions", () => {
content: [
{
type: "text",
- text: "parsed: Array task ",
+ text: "parsed: Array task ",
},
{
type: "text",
@@ -259,7 +152,7 @@ describe("processUserContentMentions", () => {
const userContent = [
{
type: "text" as const,
- text: "First task ",
+ text: "First task ",
},
{
type: "image" as const,
@@ -272,7 +165,7 @@ describe("processUserContentMentions", () => {
{
type: "tool_result" as const,
tool_use_id: "456",
- content: "Feedback ",
+ content: "Feedback ",
},
]
@@ -281,20 +174,25 @@ describe("processUserContentMentions", () => {
cwd: "/test",
urlContentFetcher: mockUrlContentFetcher,
fileContextTracker: mockFileContextTracker,
- maxReadFileLine: 50,
})
expect(parseMentions).toHaveBeenCalledTimes(2)
expect(result.content).toHaveLength(3)
expect(result.content[0]).toEqual({
type: "text",
- text: "parsed: First task ",
+ 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,7 +203,7 @@ describe("processUserContentMentions", () => {
const userContent = [
{
type: "text" as const,
- text: "Test default ",
+ text: "Test default ",
},
]
@@ -317,7 +215,7 @@ describe("processUserContentMentions", () => {
})
expect(parseMentions).toHaveBeenCalledWith(
- "Test default ",
+ "Test default ",
"/test",
mockUrlContentFetcher,
mockFileContextTracker,
@@ -325,7 +223,6 @@ describe("processUserContentMentions", () => {
false, // showRooIgnoredFiles should default to false
true, // includeDiagnosticMessages
50, // maxDiagnosticMessages
- undefined,
)
})
@@ -333,7 +230,7 @@ describe("processUserContentMentions", () => {
const userContent = [
{
type: "text" as const,
- text: "Test explicit false ",
+ text: "Test explicit false ",
},
]
@@ -346,7 +243,7 @@ describe("processUserContentMentions", () => {
})
expect(parseMentions).toHaveBeenCalledWith(
- "Test explicit false ",
+ "Test explicit false ",
"/test",
mockUrlContentFetcher,
mockFileContextTracker,
@@ -354,8 +251,127 @@ describe("processUserContentMentions", () => {
false,
true, // includeDiagnosticMessages
50, // maxDiagnosticMessages
- undefined,
)
})
})
+
+ describe("slash command content processing", () => {
+ it("should separate slash command content into a new block", async () => {
+ vi.mocked(parseMentions).mockResolvedValueOnce({
+ text: "parsed text",
+ slashCommandHelp: "command help",
+ mode: undefined,
+ contentBlocks: [],
+ })
+
+ const userContent = [
+ {
+ type: "text" as const,
+ text: "Run command ",
+ },
+ ]
+
+ const result = await processUserContentMentions({
+ userContent,
+ cwd: "/test",
+ urlContentFetcher: mockUrlContentFetcher,
+ fileContextTracker: mockFileContextTracker,
+ })
+
+ expect(result.content).toHaveLength(2)
+ expect(result.content[0]).toEqual({
+ type: "text",
+ text: "parsed text",
+ })
+ expect(result.content[1]).toEqual({
+ type: "text",
+ text: "command help",
+ })
+ })
+
+ it("should include slash command content in tool_result string content", async () => {
+ vi.mocked(parseMentions).mockResolvedValueOnce({
+ text: "parsed tool output",
+ slashCommandHelp: "command help",
+ mode: undefined,
+ contentBlocks: [],
+ })
+
+ const userContent = [
+ {
+ type: "tool_result" as const,
+ tool_use_id: "123",
+ content: "Tool output ",
+ },
+ ]
+
+ const result = await processUserContentMentions({
+ userContent,
+ cwd: "/test",
+ urlContentFetcher: mockUrlContentFetcher,
+ fileContextTracker: mockFileContextTracker,
+ })
+
+ expect(result.content).toHaveLength(1)
+ expect(result.content[0]).toEqual({
+ type: "tool_result",
+ tool_use_id: "123",
+ content: [
+ {
+ type: "text",
+ text: "parsed tool output",
+ },
+ {
+ type: "text",
+ text: "command help",
+ },
+ ],
+ })
+ })
+
+ it("should include slash command content in tool_result array content", async () => {
+ vi.mocked(parseMentions).mockResolvedValueOnce({
+ text: "parsed array item",
+ slashCommandHelp: "command help",
+ mode: undefined,
+ contentBlocks: [],
+ })
+
+ const userContent = [
+ {
+ type: "tool_result" as const,
+ tool_use_id: "123",
+ content: [
+ {
+ type: "text" as const,
+ text: "Array item ",
+ },
+ ],
+ },
+ ]
+
+ const result = await processUserContentMentions({
+ userContent,
+ cwd: "/test",
+ urlContentFetcher: mockUrlContentFetcher,
+ fileContextTracker: mockFileContextTracker,
+ })
+
+ expect(result.content).toHaveLength(1)
+ expect(result.content[0]).toEqual({
+ type: "tool_result",
+ tool_use_id: "123",
+ content: [
+ {
+ type: "text",
+ text: "parsed array item",
+ },
+ {
+ type: "text",
+ text: "command help",
+ },
+ ],
+ })
+ })
+ })
})
diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts
index 2bbbf9ed0d..faa7236e67 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"
@@ -71,11 +72,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,
@@ -85,10 +134,10 @@ export async function parseMentions(
showRooIgnoredFiles: boolean = false,
includeDiagnosticMessages: boolean = true,
maxDiagnosticMessages: number = 50,
- maxReadFileLine?: 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
@@ -118,7 +167,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)) {
@@ -126,16 +175,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") {
@@ -188,31 +238,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,
+ 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 {
@@ -246,6 +291,7 @@ export async function parseMentions(
}
// Process valid command mentions using cached results
+ let slashCommandHelp = ""
for (const [commandName, command] of validCommands) {
try {
let commandOutput = ""
@@ -253,9 +299,9 @@ export async function parseMentions(
commandOutput += `Description: ${command.description}\n\n`
}
commandOutput += command.content
- parsedText += `\n\n\n${commandOutput}\n `
+ slashCommandHelp += `\n\n\n${commandOutput}\n `
} catch (error) {
- parsedText += `\n\n\nError loading command '${commandName}': ${error.message}\n `
+ slashCommandHelp += `\n\n\nError loading command '${commandName}': ${error.message}\n `
}
}
@@ -267,18 +313,28 @@ export async function parseMentions(
}
}
- return { text: parsedText, mode: commandMode }
+ 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,
-): 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)
@@ -288,21 +344,50 @@ async function getFileOrFolderContent(
// Image mentions are handled separately via image attachment flow.
const isBinary = await isBinaryFile(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)
- 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: Promise[] = []
+ let folderListing = ""
+ const fileReadResults: string[] = []
const LOCK_SYMBOL = "🔒"
for (let index = 0; index < entries.length; index++) {
@@ -323,38 +408,48 @@ 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) {
const filePath = path.join(mentionPath, entry.name)
const absoluteFilePath = path.resolve(absPath, entry.name)
- fileContentPromises.push(
- (async () => {
- try {
- const isBinary = await isBinaryFile(absoluteFilePath).catch(() => false)
- if (isBinary) {
- return undefined
- }
- const content = await extractTextFromFile(absoluteFilePath, maxReadFileLine)
- return `\n${content}\n `
- } catch (error) {
- return undefined
- }
- })(),
- )
+ try {
+ const isBinary = await isBinaryFile(absoluteFilePath).catch(() => false)
+ if (!isBinary) {
+ const result = await extractTextFromFileWithMetadata(absoluteFilePath)
+ fileReadResults.push(formatFileReadResult(filePath.toPosix(), result))
+ }
+ } 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 fileContents = (await Promise.all(fileContentPromises)).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")}`
+ }
+
+ 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 5ea78f4dc3..d27f2cae66 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"
@@ -9,7 +9,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,
@@ -20,7 +36,6 @@ export async function processUserContentMentions({
showRooIgnoredFiles = false,
includeDiagnosticMessages = true,
maxDiagnosticMessages = 50,
- maxReadFileLine,
}: {
userContent: Anthropic.Messages.ContentBlockParam[]
cwd: string
@@ -30,7 +45,6 @@ export async function processUserContentMentions({
showRooIgnoredFiles?: boolean
includeDiagnosticMessages?: boolean
maxDiagnosticMessages?: number
- maxReadFileLine?: number
}): Promise {
// Track the first mode found from slash commands
let commandMode: string | undefined
@@ -38,50 +52,19 @@ export async function processUserContentMentions({
// Process userContent array, which contains various block types:
// TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam.
// We need to apply parseMentions() to:
- // 1. All TextBlockParam's text (first user message with task)
+ // 1. All TextBlockParam's text (first user message)
// 2. ToolResultBlockParam's content/context text arrays if it contains
- // "" (see formatToolDeniedFeedback, attemptCompletion,
- // executeCommand, and consecutiveMistakeCount >= 3) or ""
- // (see askFollowupQuestion), we place all user generated content in
- // these tags so they can effectively be used as markers for when we
- // should parse mentions).
- const content = await Promise.all(
- userContent.map(async (block) => {
- const shouldProcessMentions = (text: string) =>
- text.includes("") ||
- text.includes("") ||
- text.includes("") ||
- text.includes("")
+ // "" - we place all user generated content in this tag
+ // so it can effectively be used as a marker for when we should parse mentions.
+ const content = (
+ await Promise.all(
+ userContent.map(async (block) => {
+ const shouldProcessMentions = (text: string) => text.includes("")
- if (block.type === "text") {
- if (shouldProcessMentions(block.text)) {
- const result = await parseMentions(
- block.text,
- cwd,
- urlContentFetcher,
- fileContextTracker,
- rooIgnoreController,
- showRooIgnoredFiles,
- includeDiagnosticMessages,
- maxDiagnosticMessages,
- maxReadFileLine,
- )
- // Capture the first mode found
- if (!commandMode && result.mode) {
- commandMode = result.mode
- }
- return {
- ...block,
- text: result.text,
- }
- }
-
- return block
- } else if (block.type === "tool_result") {
- if (typeof block.content === "string") {
- if (shouldProcessMentions(block.content)) {
+ if (block.type === "text") {
+ if (shouldProcessMentions(block.text)) {
const result = await parseMentions(
- block.content,
+ block.text,
cwd,
urlContentFetcher,
fileContextTracker,
@@ -89,57 +72,146 @@ export async function processUserContentMentions({
showRooIgnoredFiles,
includeDiagnosticMessages,
maxDiagnosticMessages,
- maxReadFileLine,
)
// Capture the first mode found
if (!commandMode && result.mode) {
commandMode = result.mode
}
- return {
- ...block,
- content: result.text,
+
+ // 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,
+ text: result.slashCommandHelp,
+ })
+ }
+ return blocks
}
return block
- } else if (Array.isArray(block.content)) {
- const parsedContent = await Promise.all(
- block.content.map(async (contentBlock) => {
- if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
- const result = await parseMentions(
- contentBlock.text,
- cwd,
- urlContentFetcher,
- fileContextTracker,
- rooIgnoreController,
- showRooIgnoredFiles,
- includeDiagnosticMessages,
- maxDiagnosticMessages,
- maxReadFileLine,
- )
- // Capture the first mode found
- if (!commandMode && result.mode) {
- commandMode = result.mode
- }
- return {
- ...contentBlock,
- text: result.text,
- }
+ } else if (block.type === "tool_result") {
+ if (typeof block.content === "string") {
+ if (shouldProcessMentions(block.content)) {
+ const result = await parseMentions(
+ block.content,
+ cwd,
+ urlContentFetcher,
+ fileContextTracker,
+ rooIgnoreController,
+ showRooIgnoredFiles,
+ includeDiagnosticMessages,
+ maxDiagnosticMessages,
+ )
+ // Capture the first mode found
+ if (!commandMode && result.mode) {
+ commandMode = result.mode
}
- return contentBlock
- }),
- )
+ // Build content array with file blocks included
+ const contentParts: Array<{ type: "text"; text: string }> = [
+ {
+ type: "text" as const,
+ text: result.text,
+ },
+ ]
- return { ...block, content: parsedContent }
+ // Add file/folder content blocks
+ for (const contentBlock of result.contentBlocks) {
+ contentParts.push({
+ type: "text" as const,
+ text: contentBlock.content,
+ })
+ }
+
+ if (result.slashCommandHelp) {
+ contentParts.push({
+ type: "text" as const,
+ text: result.slashCommandHelp,
+ })
+ }
+
+ return {
+ ...block,
+ content: contentParts,
+ }
+ }
+
+ return block
+ } else if (Array.isArray(block.content)) {
+ const parsedContent = (
+ await Promise.all(
+ block.content.map(async (contentBlock) => {
+ if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
+ const result = await parseMentions(
+ contentBlock.text,
+ cwd,
+ urlContentFetcher,
+ fileContextTracker,
+ rooIgnoreController,
+ showRooIgnoredFiles,
+ includeDiagnosticMessages,
+ maxDiagnosticMessages,
+ )
+ // Capture the first mode found
+ if (!commandMode && result.mode) {
+ commandMode = result.mode
+ }
+
+ // Build blocks array with file content
+ const blocks: Array<{ type: "text"; text: string }> = [
+ {
+ ...contentBlock,
+ text: result.text,
+ },
+ ]
+
+ // Add file/folder content blocks
+ for (const cb of result.contentBlocks) {
+ blocks.push({
+ type: "text" as const,
+ text: cb.content,
+ })
+ }
+
+ if (result.slashCommandHelp) {
+ blocks.push({
+ type: "text" as const,
+ text: result.slashCommandHelp,
+ })
+ }
+ return blocks
+ }
+
+ return contentBlock
+ }),
+ )
+ ).flat()
+
+ return { ...block, content: parsedContent }
+ }
+
+ return block
}
return block
- }
-
- return block
- }),
- )
+ }),
+ )
+ ).flat()
return { content, mode: commandMode }
}
diff --git a/src/core/message-manager/index.spec.ts b/src/core/message-manager/index.spec.ts
index e2c11db3b7..3fd99793bf 100644
--- a/src/core/message-manager/index.spec.ts
+++ b/src/core/message-manager/index.spec.ts
@@ -146,7 +146,7 @@ describe("MessageManager", () => {
},
{
ts: 299,
- role: "assistant",
+ role: "user",
content: [{ type: "text", text: "Summary" }],
isSummary: true,
condenseId,
@@ -184,7 +184,7 @@ describe("MessageManager", () => {
},
{
ts: 299,
- role: "assistant",
+ role: "user",
content: [{ type: "text", text: "Summary" }],
isSummary: true,
condenseId,
@@ -220,7 +220,7 @@ describe("MessageManager", () => {
},
{
ts: 199,
- role: "assistant",
+ role: "user",
content: [{ type: "text", text: "Summary" }],
isSummary: true,
condenseId,
@@ -258,7 +258,7 @@ describe("MessageManager", () => {
{ ts: 100, role: "user", content: [{ type: "text", text: "First" }] },
{
ts: 199,
- role: "assistant",
+ role: "user",
content: [{ type: "text", text: "Summary 1" }],
isSummary: true,
condenseId: condenseId1,
@@ -266,7 +266,7 @@ describe("MessageManager", () => {
{ ts: 300, role: "user", content: [{ type: "text", text: "Second" }] },
{
ts: 399,
- role: "assistant",
+ role: "user",
content: [{ type: "text", text: "Summary 2" }],
isSummary: true,
condenseId: condenseId2,
@@ -448,7 +448,7 @@ describe("MessageManager", () => {
},
{
ts: 499,
- role: "assistant",
+ role: "user",
content: [{ type: "text", text: "Summary" }],
isSummary: true,
condenseId,
diff --git a/src/core/message-manager/index.ts b/src/core/message-manager/index.ts
index e35f290c39..4b68be0825 100644
--- a/src/core/message-manager/index.ts
+++ b/src/core/message-manager/index.ts
@@ -1,7 +1,10 @@
+import * as path from "path"
import { Task } from "../task/Task"
import { ClineMessage } from "@roo-code/types"
import { ApiMessage } from "../task-persistence/apiMessages"
import { cleanupAfterTruncation } from "../condense"
+import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor"
+import { getTaskDirectoryPath } from "../../utils/storage"
export interface RewindOptions {
/** Whether to include the target message in deletion (edit=true, delete=false) */
@@ -207,6 +210,32 @@ export class MessageManager {
apiHistory = cleanupAfterTruncation(apiHistory)
}
+ // Step 6: Cleanup orphaned command output artifacts
+ // Collect timestamps from remaining messages to identify valid artifact IDs
+ // Artifacts whose IDs don't match any remaining message timestamp will be removed
+ if (!skipCleanup) {
+ const validIds = new Set()
+
+ // Collect timestamps from remaining clineMessages
+ for (const msg of this.task.clineMessages) {
+ if (msg.ts) {
+ validIds.add(String(msg.ts))
+ }
+ }
+
+ // Collect timestamps from remaining apiHistory
+ for (const msg of apiHistory) {
+ if (msg.ts) {
+ validIds.add(String(msg.ts))
+ }
+ }
+
+ // Cleanup artifacts asynchronously (fire-and-forget with error handling)
+ this.cleanupOrphanedArtifacts(validIds).catch((error) => {
+ console.error("[MessageManager] Error cleaning up orphaned command output artifacts:", error)
+ })
+ }
+
// Only write if the history actually changed
const historyChanged =
apiHistory.length !== originalHistory.length || apiHistory.some((msg, i) => msg !== originalHistory[i])
@@ -215,4 +244,28 @@ export class MessageManager {
await this.task.overwriteApiConversationHistory(apiHistory)
}
}
+
+ /**
+ * Cleanup orphaned command output artifacts.
+ * Removes artifact files whose execution IDs don't match any remaining message timestamps.
+ */
+ private async cleanupOrphanedArtifacts(validIds: Set): Promise