mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
Merge branch 'main' into feature/MKT-67-blog-content-pipeline
This commit is contained in:
commit
3ae06b63b1
90 changed files with 5941 additions and 542 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -1,5 +1,18 @@
|
|||
# Roo Code Changelog
|
||||
|
||||
## [3.45.0] - 2026-01-27
|
||||
|
||||

|
||||
|
||||
- Smart Code Folding: Context condensation now intelligently preserves a lightweight map of files you worked on—function signatures, class declarations, and type definitions—so Roo can continue referencing them accurately after condensing. Files are prioritized by most recent access, with a ~50k character budget ensuring your latest work is always preserved. (Idea by @shariqriazz, PR #10942 by @hannesrudolph)
|
||||
|
||||
## [3.44.2] - 2026-01-27
|
||||
|
||||
- Re-enable parallel tool calling with new_task isolation safeguards (PR #11006 by @mrubens)
|
||||
- Fix worktree indexing by using relative paths in isPathInIgnoredDirectory (PR #11009 by @daniel-lxs)
|
||||
- Fix local model validation error for Ollama models (PR #10893 by @roomote)
|
||||
- Fix duplicate tool_call emission from Responses API providers (PR #11008 by @daniel-lxs)
|
||||
|
||||
## [3.44.1] - 2026-01-27
|
||||
|
||||
- Fix LiteLLM tool ID validation errors for Bedrock proxy (PR #10990 by @daniel-lxs)
|
||||
|
|
|
|||
413
apps/web-roo-code/src/app/linear/page.tsx
Normal file
413
apps/web-roo-code/src/app/linear/page.tsx
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
import {
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
CreditCard,
|
||||
Eye,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
Link2,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
import { AnimatedBackground } from "@/components/homepage"
|
||||
import { LinearIssueDemo } from "@/components/linear/linear-issue-demo"
|
||||
import { Button } from "@/components/ui"
|
||||
import { EXTERNAL_LINKS } from "@/lib/constants"
|
||||
import { SEO } from "@/lib/seo"
|
||||
import { ogImageUrl } from "@/lib/og"
|
||||
|
||||
const TITLE = "Roo Code for Linear"
|
||||
const DESCRIPTION = "Assign development work to @Roo Code directly from Linear. Get PRs back without switching tools."
|
||||
const OG_DESCRIPTION = "Turn Linear Issues into Pull Requests"
|
||||
const PATH = "/linear"
|
||||
|
||||
// Featured Workflow section is temporarily commented out until video is ready
|
||||
// const LINEAR_DEMO_YOUTUBE_ID = ""
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
alternates: {
|
||||
canonical: `${SEO.url}${PATH}`,
|
||||
},
|
||||
openGraph: {
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
url: `${SEO.url}${PATH}`,
|
||||
siteName: SEO.name,
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl(TITLE, OG_DESCRIPTION),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: TITLE,
|
||||
},
|
||||
],
|
||||
locale: SEO.locale,
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: SEO.twitterCard,
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
|
||||
},
|
||||
keywords: [
|
||||
...SEO.keywords,
|
||||
"linear integration",
|
||||
"issue to PR",
|
||||
"AI in Linear",
|
||||
"engineering workflow automation",
|
||||
"Roo Code Cloud",
|
||||
],
|
||||
}
|
||||
|
||||
// Invalidate cache when a request comes in, at most once every hour.
|
||||
export const revalidate = 3600
|
||||
|
||||
type ValueProp = {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const VALUE_PROPS: ValueProp[] = [
|
||||
{
|
||||
icon: GitBranch,
|
||||
title: "Work where you already work.",
|
||||
description:
|
||||
"Assign development work to @Roo Code directly from Linear. No new tools to learn, no context switching required.",
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
title: "Progress is visible.",
|
||||
description:
|
||||
"Watch progress unfold in real-time. Roo Code posts updates as comments, so your whole team stays in the loop.",
|
||||
},
|
||||
{
|
||||
icon: MessageSquare,
|
||||
title: "Mention for refinement.",
|
||||
description:
|
||||
'Need changes? Just comment "@Roo Code also add dark mode support" and the agent picks up where it left off.',
|
||||
},
|
||||
{
|
||||
icon: Link2,
|
||||
title: "Full traceability.",
|
||||
description:
|
||||
"Every PR links back to the originating issue. Every issue shows its linked PR. Your audit trail stays clean.",
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: "Organization-level setup.",
|
||||
description:
|
||||
"Connect once, use everywhere. Your team members can assign issues to @Roo Code without individual configuration.",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Safe by design.",
|
||||
description:
|
||||
"Agents never touch main/master directly. They produce branches and PRs. You review and approve before merge.",
|
||||
},
|
||||
]
|
||||
|
||||
// type WorkflowStep = {
|
||||
// step: number
|
||||
// title: string
|
||||
// description: string
|
||||
// }
|
||||
|
||||
// const WORKFLOW_STEPS: WorkflowStep[] = [
|
||||
// {
|
||||
// step: 1,
|
||||
// title: "Create an issue",
|
||||
// description: "Write your issue with acceptance criteria. Be as detailed as you like.",
|
||||
// },
|
||||
// {
|
||||
// step: 2,
|
||||
// title: "Call @Roo Code",
|
||||
// description: "Mention @Roo Code in a comment to start. The agent begins working immediately.",
|
||||
// },
|
||||
// {
|
||||
// step: 3,
|
||||
// title: "Watch progress",
|
||||
// description: "Roo Code posts status updates as comments. Refine with @-mentions if needed.",
|
||||
// },
|
||||
// {
|
||||
// step: 4,
|
||||
// title: "Review the PR",
|
||||
// description: "When ready, the PR link appears in the issue. Review, iterate, and ship.",
|
||||
// },
|
||||
// ]
|
||||
|
||||
type OnboardingStep = {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
link?: {
|
||||
href: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: "1. Team Plan",
|
||||
description: "Linear integration requires a Team plan.",
|
||||
link: {
|
||||
href: EXTERNAL_LINKS.CLOUD_APP_TEAM_TRIAL,
|
||||
text: "Start a free trial",
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: GitPullRequest,
|
||||
title: "2. Connect GitHub",
|
||||
description: "Link your repositories so Roo Code can open PRs on your behalf.",
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: "3. Connect Linear",
|
||||
description: "Authorize via OAuth. No API keys to manage or rotate.",
|
||||
},
|
||||
{
|
||||
icon: CheckCircle,
|
||||
title: "4. Link & Start",
|
||||
description: "Map your Linear project to a repo, then assign or mention @Roo Code.",
|
||||
},
|
||||
]
|
||||
|
||||
function LinearIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LinearPage(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{/* Hero Section */}
|
||||
<section className="relative flex pt-32 pb-20 items-center overflow-hidden">
|
||||
<AnimatedBackground />
|
||||
<div className="container relative flex flex-col items-center h-full z-10 mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid w-full max-w-6xl grid-cols-1 items-center gap-10 lg:grid-cols-2 lg:gap-12">
|
||||
<div className="text-center lg:text-left">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 text-sm font-medium mb-6">
|
||||
<LinearIcon className="size-4" />
|
||||
Powered by Roo Code Cloud
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-6 md:text-5xl lg:text-6xl">
|
||||
Turn Linear Issues into <span className="text-indigo-500">Pull Requests</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto lg:mx-0">
|
||||
Assign development work to @Roo Code directly from Linear. Get PRs back without
|
||||
switching tools.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
|
||||
<Button
|
||||
size="xl"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white transition-all duration-300 shadow-lg hover:shadow-indigo-500/25"
|
||||
asChild>
|
||||
<a
|
||||
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP_HOME}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center">
|
||||
Get Started
|
||||
<ArrowRight className="ml-2 size-5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center lg:justify-end">
|
||||
<LinearIssueDemo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Value Props Section */}
|
||||
<section className="py-24 bg-muted/30">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative">
|
||||
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2 z-1">
|
||||
<div className="absolute left-1/2 top-1/2 h-[800px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-indigo-500/10 dark:bg-indigo-700/20 blur-[140px]" />
|
||||
</div>
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
|
||||
Why your team will love using Roo Code in Linear
|
||||
</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
AI agents that understand context, keep your team in the loop, and deliver PRs you can
|
||||
review.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto relative">
|
||||
{VALUE_PROPS.map((prop, index) => {
|
||||
const Icon = prop.icon
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-background p-8 rounded-2xl border border-border hover:shadow-lg transition-all duration-300">
|
||||
<div className="bg-indigo-100 dark:bg-indigo-900/20 w-12 h-12 rounded-lg flex items-center justify-center mb-6">
|
||||
<Icon className="size-6 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3">{prop.title}</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">{prop.description}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Workflow Section - temporarily commented out until video is ready
|
||||
<section id="demo" className="relative overflow-hidden border-t border-border py-24 lg:py-32">
|
||||
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2 z-1">
|
||||
<div className="absolute left-1/2 top-1/2 h-[400px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/10 dark:bg-blue-700/20 blur-[140px]" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-12 max-w-5xl text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-sm font-medium mb-6">
|
||||
<Zap className="size-4" />
|
||||
Featured Workflow
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-5xl mb-4">Issue to Shipped Feature</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Stay in Linear from assignment to review. Roo Code keeps the issue updated and links the PR
|
||||
when it's ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10 items-center">
|
||||
{/* YouTube Video Embed or Placeholder */}
|
||||
{/*<div className="lg:col-span-3 overflow-hidden rounded-2xl border border-border bg-background shadow-lg">
|
||||
{LINEAR_DEMO_YOUTUBE_ID ? (
|
||||
<iframe
|
||||
className="aspect-video w-full"
|
||||
src={`https://www.youtube-nocookie.com/embed/${LINEAR_DEMO_YOUTUBE_ID}?rel=0`}
|
||||
title="Roo Code Linear Integration Demo"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-video w-full flex flex-col items-center justify-center bg-gradient-to-br from-indigo-500/10 via-blue-500/5 to-purple-500/10 text-center p-8">
|
||||
<LinearIcon className="size-16 text-indigo-500/50 mb-4" />
|
||||
<p className="text-lg font-semibold text-foreground mb-2">
|
||||
Demo Video Coming Soon
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
See the workflow in action: assign an issue to @Roo Code and watch as it
|
||||
analyzes requirements, writes code, and opens a PR.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow Steps */}
|
||||
{/*<div className="lg:col-span-2 space-y-3">
|
||||
{WORKFLOW_STEPS.map((step) => (
|
||||
<div
|
||||
key={step.step}
|
||||
className="relative border border-border rounded-xl bg-background p-4 transition-all duration-300 hover:shadow-md hover:border-blue-500/30">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-blue-100 dark:bg-blue-900/30 w-7 h-7 rounded-full flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-xs shrink-0 mt-0.5">
|
||||
{step.step}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-foreground mb-0.5">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-snug text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
*/}
|
||||
|
||||
{/* Onboarding Section */}
|
||||
<section className="py-24 bg-muted/30">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">Get started in minutes</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Connect Linear and start assigning issues to AI.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-5xl mx-auto">
|
||||
{ONBOARDING_STEPS.map((step, index) => {
|
||||
const Icon = step.icon
|
||||
return (
|
||||
<div key={index} className="text-center">
|
||||
<div className="bg-indigo-100 dark:bg-indigo-900/20 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Icon className="size-8 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{step.title}</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{step.description}
|
||||
{step.link && (
|
||||
<>
|
||||
{" "}
|
||||
<a
|
||||
href={step.link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
{step.link.text} →
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-24">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-4xl rounded-3xl border border-border/50 bg-gradient-to-br from-indigo-500/10 via-purple-500/5 to-blue-500/5 p-8 text-center shadow-2xl backdrop-blur-xl dark:border-white/10 sm:p-16">
|
||||
<h2 className="mb-6 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Start using Roo Code in Linear
|
||||
</h2>
|
||||
<p className="mx-auto mb-10 max-w-2xl text-lg text-muted-foreground">
|
||||
Start a free 14 day Team trial.
|
||||
</p>
|
||||
<div className="flex flex-col justify-center space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-foreground text-background hover:bg-foreground/90 transition-all duration-300"
|
||||
asChild>
|
||||
<a
|
||||
href={EXTERNAL_LINKS.CLOUD_APP_TEAM_TRIAL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center">
|
||||
Start free trial
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,6 +15,14 @@ import { ScrollButton } from "@/components/ui"
|
|||
import ThemeToggle from "@/components/chromes/theme-toggle"
|
||||
import { Brain, ChevronDown, Cloud, Puzzle, Slack, X } from "lucide-react"
|
||||
|
||||
function LinearIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface NavBarProps {
|
||||
stars: string | null
|
||||
downloads: string | null
|
||||
|
|
@ -60,6 +68,12 @@ export function NavBar({ stars, downloads }: NavBarProps) {
|
|||
<Slack className="size-3 inline mr-2 -mt-0.5" />
|
||||
Roo Code for Slack
|
||||
</Link>
|
||||
<Link
|
||||
href="/linear"
|
||||
className="block px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-foreground">
|
||||
<LinearIcon className="size-3 inline mr-2 -mt-0.5" />
|
||||
Roo Code for Linear
|
||||
</Link>
|
||||
<Link
|
||||
href="/provider"
|
||||
className="block px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-foreground">
|
||||
|
|
@ -202,6 +216,12 @@ export function NavBar({ stars, downloads }: NavBarProps) {
|
|||
onClick={() => setIsMenuOpen(false)}>
|
||||
Roo Code for Slack
|
||||
</Link>
|
||||
<Link
|
||||
href="/linear"
|
||||
className="block w-full p-5 py-3 text-left text-foreground active:opacity-50"
|
||||
onClick={() => setIsMenuOpen(false)}>
|
||||
Roo Code for Linear
|
||||
</Link>
|
||||
<Link
|
||||
href="/provider"
|
||||
className="block w-full p-5 py-3 text-left text-foreground active:opacity-50"
|
||||
|
|
|
|||
442
apps/web-roo-code/src/components/linear/linear-issue-demo.tsx
Normal file
442
apps/web-roo-code/src/components/linear/linear-issue-demo.tsx
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ChevronRight, GitPullRequest, Paperclip, Send } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ActivityItem = {
|
||||
id: string
|
||||
kind: "comment" | "event" | "pr-link"
|
||||
author?: string
|
||||
avatarText?: string
|
||||
avatarClassName?: string
|
||||
body: ReactNode
|
||||
timeLabel: string
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={cn("inline-flex items-center gap-0.5", className)} aria-hidden="true">
|
||||
<span className="h-1 w-1 rounded-full bg-[#8B8D91] animate-pulse [animation-delay:0ms]" />
|
||||
<span className="h-1 w-1 rounded-full bg-[#8B8D91] animate-pulse [animation-delay:180ms]" />
|
||||
<span className="h-1 w-1 rounded-full bg-[#8B8D91] animate-pulse [animation-delay:360ms]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LinearIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn("flex items-center gap-2 text-[13px] text-[#8B8D91]", animation)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold",
|
||||
item.avatarClassName,
|
||||
)}>
|
||||
{item.avatarText}
|
||||
</div>
|
||||
<span className="text-[#F8F8F9]">{item.author}</span>
|
||||
<span>{item.body}</span>
|
||||
<span className="text-[#5C5F66]">·</span>
|
||||
<span>{item.timeLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// PR link events
|
||||
if (item.kind === "pr-link") {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 text-[13px] text-[#8B8D91]", animation)}>
|
||||
<GitPullRequest className="h-4 w-4 shrink-0 text-emerald-500" />
|
||||
<span>{item.body}</span>
|
||||
<span className="text-[#5C5F66]">·</span>
|
||||
<span>{item.timeLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Comment items - more substantial with message body
|
||||
return (
|
||||
<div className={cn("flex gap-2.5", animation)}>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold",
|
||||
item.avatarClassName,
|
||||
)}>
|
||||
{item.avatarText}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
<span className="font-medium text-[#F8F8F9]">{item.author}</span>
|
||||
<span className="text-[#5C5F66]">·</span>
|
||||
<span className="text-[#8B8D91]">{item.timeLabel}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[13px] leading-relaxed text-[#D1D2D3]">{item.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type LinearIssueDemoProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LinearIssueDemo({ className }: LinearIssueDemoProps): JSX.Element {
|
||||
const reduceMotion = usePrefersReducedMotion()
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const scrollViewportRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const activityItems: ActivityItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "a1",
|
||||
kind: "comment",
|
||||
author: "Jordan",
|
||||
avatarText: "J",
|
||||
avatarClassName: "bg-amber-600 text-white",
|
||||
body: (
|
||||
<span>
|
||||
<span className="text-indigo-400">@Roo Code</span> Can you implement this feature?
|
||||
</span>
|
||||
),
|
||||
timeLabel: "2m ago",
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
kind: "comment",
|
||||
author: "Roo Code",
|
||||
avatarText: "R",
|
||||
avatarClassName: "bg-indigo-600 text-white",
|
||||
body: <span>Analyzing issue requirements and codebase...</span>,
|
||||
timeLabel: "2m ago",
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
kind: "event",
|
||||
author: "Roo Code",
|
||||
avatarText: "R",
|
||||
avatarClassName: "bg-indigo-600 text-white",
|
||||
body: <span>moved to In Progress</span>,
|
||||
timeLabel: "2m ago",
|
||||
},
|
||||
{
|
||||
id: "a4",
|
||||
kind: "comment",
|
||||
author: "Roo Code",
|
||||
avatarText: "R",
|
||||
avatarClassName: "bg-indigo-600 text-white",
|
||||
body: <span>Planning implementation: Settings component with light/dark toggle.</span>,
|
||||
timeLabel: "1m ago",
|
||||
},
|
||||
{
|
||||
id: "a5",
|
||||
kind: "comment",
|
||||
author: "Jordan",
|
||||
avatarText: "J",
|
||||
avatarClassName: "bg-amber-600 text-white",
|
||||
body: (
|
||||
<span>
|
||||
<span className="text-indigo-400">@Roo Code</span> Please also add a "system" option
|
||||
that follows OS preference.
|
||||
</span>
|
||||
),
|
||||
timeLabel: "1m ago",
|
||||
},
|
||||
{
|
||||
id: "a6",
|
||||
kind: "comment",
|
||||
author: "Roo Code",
|
||||
avatarText: "R",
|
||||
avatarClassName: "bg-indigo-600 text-white",
|
||||
body: (
|
||||
<span>
|
||||
Got it! Adding system preference detection using{" "}
|
||||
<code className="rounded bg-white/10 px-1 py-0.5 text-[12px] text-[#F8F8F9]">
|
||||
prefers-color-scheme
|
||||
</code>
|
||||
</span>
|
||||
),
|
||||
timeLabel: "30s ago",
|
||||
},
|
||||
{
|
||||
id: "a7",
|
||||
kind: "pr-link",
|
||||
body: (
|
||||
<span>
|
||||
<span className="text-[#F8F8F9]">Roo Code</span> linked{" "}
|
||||
<span className="text-emerald-400">PR #847</span>
|
||||
</span>
|
||||
),
|
||||
timeLabel: "just now",
|
||||
},
|
||||
{
|
||||
id: "a8",
|
||||
kind: "comment",
|
||||
author: "Roo Code",
|
||||
avatarText: "R",
|
||||
avatarClassName: "bg-indigo-600 text-white",
|
||||
body: (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
PR ready for review:{" "}
|
||||
<span className="text-indigo-400 hover:underline cursor-default">#847</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-[12px]">
|
||||
<div className="flex items-center gap-2 text-emerald-400">
|
||||
<GitPullRequest className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">feat: add theme toggle with system preference</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[#8B8D91]">+142 -12 · 3 files changed</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
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 (
|
||||
<div
|
||||
className={cn("w-full max-w-[540px] h-[520px] sm:h-[560px]", className)}
|
||||
role="img"
|
||||
aria-label="Animated Linear issue showing Roo Code responding to a comment">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="relative flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#1F2023] shadow-2xl shadow-black/40">
|
||||
{/* Linear-style Header with breadcrumb */}
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2.5 text-[13px]">
|
||||
<LinearIcon className="h-4 w-4 text-[#8B8D91]" />
|
||||
<span className="text-[#8B8D91]">Frontend</span>
|
||||
<ChevronRight className="h-3 w-3 text-[#5C5F66]" />
|
||||
<span className="text-[#F8F8F9]">FE-312</span>
|
||||
<div className="ml-auto flex items-center gap-2 text-[11px] text-[#8B8D91]">
|
||||
<span className="h-2 w-2 rounded-full bg-[#27AE60]" />
|
||||
Live demo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-1 overflow-hidden transition-opacity duration-300 will-change-opacity",
|
||||
issueVisible ? "opacity-100" : "opacity-0",
|
||||
)}>
|
||||
{/* Issue Title */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-lg font-semibold text-[#F8F8F9] leading-tight">
|
||||
Add dark mode toggle to settings
|
||||
</h3>
|
||||
<p className="mt-2 text-[13px] text-[#8B8D91] leading-relaxed">
|
||||
Users should be able to switch between light and dark themes from the settings page. Persist
|
||||
preference to localStorage and apply immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Activity Section */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col border-t border-white/10">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between">
|
||||
<span className="text-[13px] font-medium text-[#F8F8F9]">Activity</span>
|
||||
<span className="text-[12px] text-[#5C5F66]">Unsubscribe</span>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollViewportRef}
|
||||
className="flex-1 overflow-y-auto px-4 pb-3 [scrollbar-width:thin] [scrollbar-color:rgba(255,255,255,0.1)_transparent]">
|
||||
<div className="space-y-3">
|
||||
{visibleActivities.map((item) => (
|
||||
<ActivityRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
reduceMotion={reduceMotion}
|
||||
isNew={
|
||||
activePhase.kind === "show" &&
|
||||
activityItems[activePhase.activityIndex]?.id === item.id
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{typingTarget && typingTarget.kind === "comment" && (
|
||||
<div
|
||||
className={cn(
|
||||
reduceMotion ? "" : "animate-in fade-in duration-300",
|
||||
"flex gap-2.5",
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold",
|
||||
typingTarget.avatarClassName,
|
||||
)}>
|
||||
{typingTarget.avatarText}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
<span className="font-medium text-[#F8F8F9]">
|
||||
{typingTarget.author}
|
||||
</span>
|
||||
<span className="text-[#8B8D91]">typing</span>
|
||||
<TypingDots />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment Input */}
|
||||
<div className="border-t border-white/10 px-4 py-3">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-3 py-2">
|
||||
<span className="flex-1 text-[13px] text-[#5C5F66]">Leave a comment...</span>
|
||||
<Paperclip className="h-4 w-4 text-[#5C5F66]" />
|
||||
<Send className="h-4 w-4 text-[#5C5F66]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center justify-center border-t border-white/10 px-4 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{activityItems.map((item, idx) => (
|
||||
<span
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"h-1 w-3 rounded-full transition-colors duration-300",
|
||||
Math.max(0, visibleCount - 1) === idx ? "bg-indigo-400" : "bg-white/10",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -66,7 +66,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
"clean": "rimraf dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.25.61"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@roo-code/config-eslint": "workspace:^",
|
||||
|
|
|
|||
|
|
@ -99,8 +99,6 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema
|
|||
maxWorkspaceFiles: true,
|
||||
showRooIgnoredFiles: true,
|
||||
terminalCommandDelay: true,
|
||||
terminalCompressProgressBar: true,
|
||||
terminalOutputLineLimit: true,
|
||||
terminalShellIntegrationDisabled: true,
|
||||
terminalShellIntegrationTimeout: true,
|
||||
terminalZshClearEolMark: true,
|
||||
|
|
@ -112,7 +110,6 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema
|
|||
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(),
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<TerminalOutputPreviewSize, number> = {
|
||||
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.
|
||||
|
|
@ -147,8 +176,7 @@ export const globalSettingsSchema = z.object({
|
|||
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(),
|
||||
|
|
@ -157,7 +185,6 @@ 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(),
|
||||
|
||||
|
|
@ -338,8 +365,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,
|
||||
|
|
@ -347,7 +372,6 @@ export const EVALS_SETTINGS: RooCodeSettings = {
|
|||
terminalZshClearEolMark: true,
|
||||
terminalZshP10k: false,
|
||||
terminalZdotdir: true,
|
||||
terminalCompressProgressBar: true,
|
||||
terminalShellIntegrationDisabled: true,
|
||||
|
||||
diagnosticsEnabled: true,
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ export const clineSays = [
|
|||
"codebase_search_result",
|
||||
"user_edit_todos",
|
||||
"too_many_tools_warning",
|
||||
"tool",
|
||||
] as const
|
||||
|
||||
export const clineSaySchema = z.enum(clineSays)
|
||||
|
|
|
|||
|
|
@ -32,3 +32,69 @@ export const commandExecutionStatusSchema = z.discriminatedUnion("status", [
|
|||
])
|
||||
|
||||
export type CommandExecutionStatus = z.infer<typeof commandExecutionStatusSchema>
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type ToolGroup = z.infer<typeof toolGroupsSchema>
|
|||
export const toolNames = [
|
||||
"execute_command",
|
||||
"read_file",
|
||||
"read_command_output",
|
||||
"write_to_file",
|
||||
"apply_diff",
|
||||
"search_and_replace",
|
||||
|
|
|
|||
|
|
@ -302,8 +302,7 @@ export type ExtensionState = Pick<
|
|||
| "soundEnabled"
|
||||
| "soundVolume"
|
||||
| "maxConcurrentFileReads"
|
||||
| "terminalOutputLineLimit"
|
||||
| "terminalOutputCharacterLimit"
|
||||
| "terminalOutputPreviewSize"
|
||||
| "terminalShellIntegrationTimeout"
|
||||
| "terminalShellIntegrationDisabled"
|
||||
| "terminalCommandDelay"
|
||||
|
|
@ -312,7 +311,6 @@ export type ExtensionState = Pick<
|
|||
| "terminalZshOhMy"
|
||||
| "terminalZshP10k"
|
||||
| "terminalZdotdir"
|
||||
| "terminalCompressProgressBar"
|
||||
| "diagnosticsEnabled"
|
||||
| "language"
|
||||
| "modeApiConfigs"
|
||||
|
|
@ -780,6 +778,7 @@ export interface ClineSayTool {
|
|||
| "newFileCreated"
|
||||
| "codebaseSearch"
|
||||
| "readFile"
|
||||
| "readCommandOutput"
|
||||
| "fetchInstructions"
|
||||
| "listFilesTopLevel"
|
||||
| "listFilesRecursive"
|
||||
|
|
@ -792,6 +791,12 @@ export interface ClineSayTool {
|
|||
| "runSlashCommand"
|
||||
| "updateTodoList"
|
||||
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
|
||||
|
|
|
|||
198
pnpm-lock.yaml
generated
198
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
||||
|
|
@ -268,7 +269,7 @@ importers:
|
|||
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)
|
||||
version: 15.2.8(@opentelemetry/api@1.9.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)
|
||||
|
|
@ -303,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:^
|
||||
|
|
@ -380,7 +381,7 @@ importers:
|
|||
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)
|
||||
version: 15.2.8(@opentelemetry/api@1.9.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)
|
||||
|
|
@ -421,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:^
|
||||
|
|
@ -447,7 +448,7 @@ importers:
|
|||
version: 10.4.21(postcss@8.5.4)
|
||||
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@15.2.8(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
|
||||
postcss:
|
||||
specifier: ^8.5.4
|
||||
version: 8.5.4
|
||||
|
|
@ -458,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:^
|
||||
|
|
@ -492,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':
|
||||
|
|
@ -567,7 +568,7 @@ importers:
|
|||
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':
|
||||
|
|
@ -596,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
|
||||
|
|
@ -622,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:^
|
||||
|
|
@ -684,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:^
|
||||
|
|
@ -706,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:^
|
||||
|
|
@ -768,7 +769,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
|
||||
|
|
@ -882,7 +883,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
|
||||
|
|
@ -989,9 +990,12 @@ 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:
|
||||
'@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
|
||||
|
|
@ -1064,6 +1068,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
|
||||
|
|
@ -1099,7 +1106,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:
|
||||
|
|
@ -1305,8 +1312,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:^
|
||||
|
|
@ -1374,6 +1381,22 @@ packages:
|
|||
'@adobe/css-tools@4.4.2':
|
||||
resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
|
||||
|
||||
'@ai-sdk/gateway@3.0.25':
|
||||
resolution: {integrity: sha512-j0AQeA7hOVqwImykQlganf/Euj3uEXf0h3G0O4qKTDpEwE+EZGIPnVimCWht5W91lAetPZSfavDyvfpuPDd2PQ==}
|
||||
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@3.0.5':
|
||||
resolution: {integrity: sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@alcalzone/ansi-tokenize@0.2.3':
|
||||
resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -2392,7 +2415,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==}
|
||||
|
|
@ -2591,6 +2614,17 @@ 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@1.9.0':
|
||||
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.2.0':
|
||||
resolution: {integrity: sha512-ruKLkS+Dm/YIJaUhzEB7zPI+jh3EXxu0QnNV8I7t9jf0lpD2VnltuyRbhrbJEkksklZj//xCMyFFsILGjiU2Mg==}
|
||||
cpu: [arm64]
|
||||
|
|
@ -3845,6 +3879,9 @@ 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==}
|
||||
|
||||
|
|
@ -4465,6 +4502,10 @@ packages:
|
|||
resolution: {integrity: sha512-e4kQK9mP8ntpo3dACWirGod/hHv4qO5JMj9a/0a2AZto7b4persj5YP7t1Er372gTtYFTYxNhMx34jRvHooglw==}
|
||||
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}
|
||||
|
|
@ -4617,6 +4658,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==}
|
||||
|
||||
|
|
@ -6196,6 +6243,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'}
|
||||
|
|
@ -7315,6 +7366,9 @@ 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==}
|
||||
|
||||
|
|
@ -8313,7 +8367,7 @@ packages:
|
|||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
zod: ^3.23.8
|
||||
zod: 3.25.76
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
|
|
@ -10685,25 +10739,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==}
|
||||
|
|
@ -10733,6 +10781,24 @@ snapshots:
|
|||
|
||||
'@adobe/css-tools@4.4.2': {}
|
||||
|
||||
'@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/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@3.0.5':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@alcalzone/ansi-tokenize@0.2.3':
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
|
|
@ -12247,10 +12313,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': {}
|
||||
|
||||
|
|
@ -12418,6 +12484,13 @@ 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@1.9.0': {}
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.2.0':
|
||||
optional: true
|
||||
|
||||
|
|
@ -13819,6 +13892,8 @@ snapshots:
|
|||
|
||||
'@socket.io/component-emitter@3.1.2': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@swc/counter@0.1.3': {}
|
||||
|
|
@ -14476,6 +14551,8 @@ snapshots:
|
|||
satori: 0.12.2
|
||||
yoga-wasm-web: 0.3.3
|
||||
|
||||
'@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:
|
||||
'@babel/core': 7.27.1
|
||||
|
|
@ -14548,7 +14625,7 @@ snapshots:
|
|||
sirv: 3.0.1
|
||||
tinyglobby: 0.2.14
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
|
|
@ -14698,6 +14775,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
|
||||
|
|
@ -15225,7 +15310,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:
|
||||
|
|
@ -15903,9 +15988,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
|
||||
|
|
@ -16380,6 +16466,8 @@ snapshots:
|
|||
|
||||
eventsource-parser@3.0.2: {}
|
||||
|
||||
eventsource-parser@3.0.6: {}
|
||||
|
||||
eventsource@3.0.7:
|
||||
dependencies:
|
||||
eventsource-parser: 3.0.2
|
||||
|
|
@ -17682,6 +17770,8 @@ 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: {}
|
||||
|
|
@ -18733,20 +18823,20 @@ 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@15.2.8(@opentelemetry/api@1.9.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: 15.2.8(@opentelemetry/api@1.9.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@15.2.8(@opentelemetry/api@1.9.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
|
||||
|
|
@ -18766,6 +18856,7 @@ snapshots:
|
|||
'@next/swc-linux-x64-musl': 15.2.5
|
||||
'@next/swc-win32-arm64-msvc': 15.2.5
|
||||
'@next/swc-win32-x64-msvc': 15.2.5
|
||||
'@opentelemetry/api': 1.9.0
|
||||
sharp: 0.33.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
|
@ -18938,11 +19029,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
|
||||
|
|
@ -21778,27 +21864,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):
|
||||
|
|
|
|||
BIN
releases/3.45.0-release.png
Normal file
BIN
releases/3.45.0-release.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
492
src/api/transform/__tests__/ai-sdk.spec.ts
Normal file
492
src/api/transform/__tests__/ai-sdk.spec.ts
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } 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("processes complete tool-call chunks", () => {
|
||||
const part = {
|
||||
type: "tool-call" as const,
|
||||
toolCallId: "call_1",
|
||||
toolName: "read_file",
|
||||
input: { path: "test.ts" },
|
||||
}
|
||||
const chunks = [...processAiSdkStreamPart(part)]
|
||||
|
||||
expect(chunks).toHaveLength(1)
|
||||
expect(chunks[0]).toEqual({
|
||||
type: "tool_call",
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
arguments: '{"path":"test.ts"}',
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
282
src/api/transform/ai-sdk.ts
Normal file
282
src/api/transform/ai-sdk.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* 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<string, string>()
|
||||
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<TextPart | ImagePart | FilePart>
|
||||
// ToolContent (for role: "tool") supports: Array<ToolResultPart | ToolApprovalResponse>
|
||||
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<string, ReturnType<typeof createTool>> | undefined {
|
||||
if (!tools || tools.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const toolSet: Record<string, ReturnType<typeof createTool>> = {}
|
||||
|
||||
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<any> | { 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<ApiStreamChunk> {
|
||||
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 "tool-call":
|
||||
// Complete tool call - emit for compatibility
|
||||
yield {
|
||||
type: "tool_call",
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
arguments: typeof part.input === "string" ? part.input : JSON.stringify(part.input),
|
||||
}
|
||||
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
|
||||
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 "raw":
|
||||
// These events don't need to be yielded
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -790,6 +790,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<TName>
|
||||
}
|
||||
break
|
||||
|
||||
case "write_to_file":
|
||||
if (args.path !== undefined && args.content !== undefined) {
|
||||
nativeArgs = {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { Task } from "../task/Task"
|
|||
import { fetchInstructionsTool } from "../tools/FetchInstructionsTool"
|
||||
import { listFilesTool } from "../tools/ListFilesTool"
|
||||
import { readFileTool } from "../tools/ReadFileTool"
|
||||
import { readCommandOutputTool } from "../tools/ReadCommandOutputTool"
|
||||
import { writeToFileTool } from "../tools/WriteToFileTool"
|
||||
import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool"
|
||||
import { searchReplaceTool } from "../tools/SearchReplaceTool"
|
||||
|
|
@ -402,8 +403,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": {
|
||||
|
|
@ -846,6 +849,13 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
pushToolResult,
|
||||
})
|
||||
break
|
||||
case "read_command_output":
|
||||
await readCommandOutputTool.handle(cline, block as ToolUse<"read_command_output">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
})
|
||||
break
|
||||
case "use_mcp_tool":
|
||||
await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, {
|
||||
askApproval,
|
||||
|
|
@ -1088,6 +1098,7 @@ function containsXmlToolMarkup(text: string): boolean {
|
|||
"generate_image",
|
||||
"list_files",
|
||||
"new_task",
|
||||
"read_command_output",
|
||||
"read_file",
|
||||
"search_and_replace",
|
||||
"search_files",
|
||||
|
|
|
|||
|
|
@ -136,7 +136,13 @@ Line 2
|
|||
{ role: "user", content: "Ninth message" },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// Verify we have a summary message with role "user" (fresh start model)
|
||||
const summaryMessage = result.messages.find((msg) => msg.isSummary)
|
||||
|
|
@ -164,7 +170,13 @@ Line 2
|
|||
{ role: "user", content: "Fifth message" },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// All original messages should be tagged with condenseParent
|
||||
const taggedMessages = result.messages.filter((msg) => !msg.isSummary)
|
||||
|
|
@ -193,7 +205,13 @@ Line 2
|
|||
{ role: "user", content: "Ninth message" },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
const summaryMessage = result.messages.find((msg) => msg.isSummary)
|
||||
expect(summaryMessage).toBeTruthy()
|
||||
|
|
@ -227,7 +245,13 @@ Line 2
|
|||
{ role: "user", content: "Perfect!" },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// Effective history should contain only the summary (fresh start)
|
||||
const effectiveHistory = getEffectiveApiHistory(result.messages)
|
||||
|
|
@ -239,7 +263,13 @@ Line 2
|
|||
it("should return error when not enough messages to summarize", async () => {
|
||||
const messages: ApiMessage[] = [{ role: "user", content: "Only one message" }]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// Should return an error since we have only 1 message
|
||||
expect(result.error).toBeDefined()
|
||||
|
|
@ -253,7 +283,13 @@ Line 2
|
|||
{ role: "user", content: "Previous summary", isSummary: true },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// Should return an error due to recent summary with no substantial messages after
|
||||
expect(result.error).toBeDefined()
|
||||
|
|
@ -286,7 +322,13 @@ Line 2
|
|||
{ role: "user", content: "Seventh" },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, emptyHandler, "System prompt", taskId, false)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: emptyHandler,
|
||||
systemPrompt: "System prompt",
|
||||
taskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
expect(result.error).toBeDefined()
|
||||
expect(result.messages).toEqual(messages)
|
||||
|
|
|
|||
391
src/core/condense/__tests__/foldedFileContext.spec.ts
Normal file
391
src/core/condense/__tests__/foldedFileContext.spec.ts
Normal file
|
|
@ -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<typeof import("../foldedFileContext")>()
|
||||
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 <system-reminder> block
|
||||
expect(result.content).toContain("<system-reminder>")
|
||||
expect(result.content).toContain("</system-reminder>")
|
||||
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("<system-reminder>")
|
||||
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("<system-reminder>")
|
||||
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<any>")
|
||||
.mockResolvedValueOnce("1--4 | export interface DataModel")
|
||||
|
||||
const result = await generateFoldedFileContext(["/test/api.ts", "/test/models.ts"], { cwd: "/test" })
|
||||
|
||||
// Each file should have its own <system-reminder> block
|
||||
const systemReminderMatches = result.content.match(/<system-reminder>/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<Anthropic.Messages.ContentBlockParam>): Promise<number> {
|
||||
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 = [
|
||||
`<system-reminder>
|
||||
## File Context: src/user.ts
|
||||
1--5 | export interface User
|
||||
7--12 | export function createUser(name: string): User
|
||||
14--28 | export class UserService
|
||||
</system-reminder>`,
|
||||
`<system-reminder>
|
||||
## File Context: src/api.ts
|
||||
1--3 | export async function fetchData(url: string): Promise<any>
|
||||
</system-reminder>`,
|
||||
]
|
||||
|
||||
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 <system-reminder> tags
|
||||
expect(userFileBlock.text).toContain("<system-reminder>")
|
||||
expect(userFileBlock.text).toContain("export interface User")
|
||||
|
||||
expect(apiFileBlock.text).toContain("<system-reminder>")
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -710,7 +710,12 @@ describe("summarizeConversation", () => {
|
|||
it("should not summarize when there are not enough messages", async () => {
|
||||
const messages: ApiMessage[] = [{ role: "user", content: "Hello", ts: 1 }]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
expect(result.messages).toEqual(messages)
|
||||
expect(result.cost).toBe(0)
|
||||
expect(result.summary).toBe("")
|
||||
|
|
@ -730,7 +735,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "Tell me more", ts: 7 },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Check that the API was called correctly
|
||||
expect(mockApiHandler.createMessage).toHaveBeenCalled()
|
||||
|
|
@ -784,7 +794,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "What's new?", ts: 5 },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
const summaryMessage = result.messages.find((m) => m.isSummary)
|
||||
expect(summaryMessage).toBeDefined()
|
||||
|
|
@ -807,7 +822,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "What's new?", ts: 5 },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
const summaryMessage = result.messages.find((m) => m.isSummary)
|
||||
expect(summaryMessage).toBeDefined()
|
||||
|
|
@ -844,7 +864,12 @@ describe("summarizeConversation", () => {
|
|||
return messages.map(({ role, content }: { role: string; content: any }) => ({ role, content }))
|
||||
})
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Should return original messages when summary is empty
|
||||
expect(result.messages).toEqual(messages)
|
||||
|
|
@ -865,7 +890,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "Tell me more", ts: 7 },
|
||||
]
|
||||
|
||||
await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Verify that createMessage was called with the SUMMARY_PROMPT (which contains CRITICAL instructions), messages array, and optional metadata
|
||||
expect(mockApiHandler.createMessage).toHaveBeenCalledWith(
|
||||
|
|
@ -897,7 +927,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "Newest", ts: 7 },
|
||||
]
|
||||
|
||||
await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
const mockCallArgs = (maybeRemoveImageBlocks as Mock).mock.calls[0][0] as any[]
|
||||
|
||||
|
|
@ -935,7 +970,12 @@ describe("summarizeConversation", () => {
|
|||
// Override the mock for this test
|
||||
mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, systemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Verify that countTokens was called with system prompt + summary message
|
||||
expect(mockApiHandler.countTokens).toHaveBeenCalled()
|
||||
|
|
@ -970,7 +1010,12 @@ describe("summarizeConversation", () => {
|
|||
// Mock countTokens to return a small value
|
||||
mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(30)) as any
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Result contains all messages plus summary
|
||||
expect(result.messages.length).toBe(messages.length + 1)
|
||||
|
|
@ -1010,7 +1055,12 @@ describe("summarizeConversation", () => {
|
|||
const mockError = vi.fn()
|
||||
console.error = mockError
|
||||
|
||||
const result = await summarizeConversation(messages, invalidHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: invalidHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Should return original messages when handler is invalid
|
||||
expect(result.messages).toEqual(messages)
|
||||
|
|
@ -1035,7 +1085,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "Thanks", ts: 5 },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
const summaryMessage = result.messages.find((m) => m.isSummary)
|
||||
expect(summaryMessage).toBeDefined()
|
||||
|
|
@ -1056,7 +1111,12 @@ describe("summarizeConversation", () => {
|
|||
{ role: "user", content: "Thanks", ts: 5 },
|
||||
]
|
||||
|
||||
const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt, taskId)
|
||||
const result = await summarizeConversation({
|
||||
messages,
|
||||
apiHandler: mockApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Summary should be the last message
|
||||
const lastMessage = result.messages[result.messages.length - 1]
|
||||
|
|
@ -1120,14 +1180,14 @@ describe("summarizeConversation with custom settings", () => {
|
|||
it("should use custom prompt when provided", async () => {
|
||||
const customPrompt = "Custom summarization prompt"
|
||||
|
||||
await summarizeConversation(
|
||||
sampleMessages,
|
||||
mockMainApiHandler,
|
||||
defaultSystemPrompt,
|
||||
localTaskId,
|
||||
false,
|
||||
customPrompt,
|
||||
)
|
||||
await summarizeConversation({
|
||||
messages: sampleMessages,
|
||||
apiHandler: mockMainApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId: localTaskId,
|
||||
isAutomaticTrigger: false,
|
||||
customCondensingPrompt: customPrompt,
|
||||
})
|
||||
|
||||
// Verify the custom prompt was used in the user message content
|
||||
const createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls
|
||||
|
|
@ -1144,7 +1204,14 @@ 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, localTaskId, false, " ")
|
||||
await summarizeConversation({
|
||||
messages: sampleMessages,
|
||||
apiHandler: mockMainApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId: localTaskId,
|
||||
isAutomaticTrigger: false,
|
||||
customCondensingPrompt: " ",
|
||||
})
|
||||
|
||||
// Verify the default SUMMARY_PROMPT was used (contains CRITICAL instructions)
|
||||
let createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls
|
||||
|
|
@ -1156,14 +1223,13 @@ describe("summarizeConversation with custom settings", () => {
|
|||
|
||||
// Reset mock and test with undefined
|
||||
vi.clearAllMocks()
|
||||
await summarizeConversation(
|
||||
sampleMessages,
|
||||
mockMainApiHandler,
|
||||
defaultSystemPrompt,
|
||||
localTaskId,
|
||||
false,
|
||||
undefined,
|
||||
)
|
||||
await summarizeConversation({
|
||||
messages: sampleMessages,
|
||||
apiHandler: mockMainApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId: localTaskId,
|
||||
isAutomaticTrigger: false,
|
||||
})
|
||||
|
||||
// Verify the default SUMMARY_PROMPT was used again (contains CRITICAL instructions)
|
||||
createMessageCalls = (mockMainApiHandler.createMessage as Mock).mock.calls
|
||||
|
|
@ -1178,14 +1244,14 @@ describe("summarizeConversation with custom settings", () => {
|
|||
* Test that telemetry is called for custom prompt usage
|
||||
*/
|
||||
it("should capture telemetry when using custom prompt", async () => {
|
||||
await summarizeConversation(
|
||||
sampleMessages,
|
||||
mockMainApiHandler,
|
||||
defaultSystemPrompt,
|
||||
localTaskId,
|
||||
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(
|
||||
|
|
@ -1199,14 +1265,14 @@ describe("summarizeConversation with custom settings", () => {
|
|||
* Test that telemetry is called with isAutomaticTrigger flag
|
||||
*/
|
||||
it("should capture telemetry with isAutomaticTrigger flag", async () => {
|
||||
await summarizeConversation(
|
||||
sampleMessages,
|
||||
mockMainApiHandler,
|
||||
defaultSystemPrompt,
|
||||
localTaskId,
|
||||
true, // isAutomaticTrigger
|
||||
"Custom prompt",
|
||||
)
|
||||
await summarizeConversation({
|
||||
messages: sampleMessages,
|
||||
apiHandler: mockMainApiHandler,
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
taskId: localTaskId,
|
||||
isAutomaticTrigger: true,
|
||||
customCondensingPrompt: "Custom prompt",
|
||||
})
|
||||
|
||||
// Verify telemetry was called with isAutomaticTrigger flag
|
||||
expect(TelemetryService.instance.captureContextCondensed).toHaveBeenCalledWith(
|
||||
|
|
|
|||
168
src/core/condense/foldedFileContext.ts
Normal file
168
src/core/condense/foldedFileContext.ts
Normal file
|
|
@ -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 <system-reminder> 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 `<system-reminder>` 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 <system-reminder> blocks for each file:
|
||||
* // <system-reminder>
|
||||
* // ## File Context: src/utils/helpers.ts
|
||||
* // 1--15 | export function formatDate(...)
|
||||
* // 17--45 | export class DateHelper {...}
|
||||
* // </system-reminder>
|
||||
* // <system-reminder>
|
||||
* // ## File Context: src/api/client.ts
|
||||
* // ...
|
||||
* // </system-reminder>
|
||||
* ```
|
||||
*/
|
||||
export async function generateFoldedFileContext(
|
||||
filePaths: string[],
|
||||
options: FoldedFileContextOptions,
|
||||
): Promise<FoldedFileContextResult> {
|
||||
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 <system-reminder> block
|
||||
const sectionContent = `<system-reminder>
|
||||
## File Context: ${filePath}
|
||||
${definitions}
|
||||
</system-reminder>`
|
||||
|
||||
// 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 = `<system-reminder>
|
||||
## File Context: ${filePath}
|
||||
${truncatedDefinitions}
|
||||
</system-reminder>`
|
||||
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
|
||||
}
|
||||
|
|
@ -9,6 +9,10 @@ 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"
|
||||
|
||||
export type { FoldedFileContextResult, FoldedFileContextOptions } from "./foldedFileContext"
|
||||
|
||||
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
|
||||
|
|
@ -123,6 +127,20 @@ export type SummarizeResponse = {
|
|||
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.
|
||||
*
|
||||
|
|
@ -131,6 +149,7 @@ export type SummarizeResponse = {
|
|||
* - Post-condense, the model sees only the summary (true fresh start)
|
||||
* - All messages are still stored but tagged with condenseParent
|
||||
* - <command> blocks from the original task are preserved across condensings
|
||||
* - File context (folded code definitions) can be preserved for continuity
|
||||
*
|
||||
* Environment details handling:
|
||||
* - For AUTOMATIC condensing (isAutomaticTrigger=true): Environment details are included
|
||||
|
|
@ -139,27 +158,21 @@ export type SummarizeResponse = {
|
|||
* - 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().
|
||||
*
|
||||
* @param {ApiMessage[]} messages - The conversation messages
|
||||
* @param {ApiHandler} apiHandler - The API handler to use for summarization and token counting
|
||||
* @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 {boolean} isAutomaticTrigger - Whether the summarization is triggered automatically
|
||||
* @param {string} customCondensingPrompt - Optional custom prompt to use for condensing
|
||||
* @param {ApiHandlerCreateMessageMetadata} metadata - Optional metadata to pass to createMessage (tools, taskId, etc.)
|
||||
* @param {string} environmentDetails - Optional environment details string to include in the summary (only used when isAutomaticTrigger=true)
|
||||
* @returns {SummarizeResponse} - The result of the summarization operation (see above)
|
||||
*/
|
||||
export async function summarizeConversation(
|
||||
messages: ApiMessage[],
|
||||
apiHandler: ApiHandler,
|
||||
systemPrompt: string,
|
||||
taskId: string,
|
||||
isAutomaticTrigger?: boolean,
|
||||
customCondensingPrompt?: string,
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
environmentDetails?: string,
|
||||
): Promise<SummarizeResponse> {
|
||||
export async function summarizeConversation(options: SummarizeConversationOptions): Promise<SummarizeResponse> {
|
||||
const {
|
||||
messages,
|
||||
apiHandler,
|
||||
systemPrompt,
|
||||
taskId,
|
||||
isAutomaticTrigger,
|
||||
customCondensingPrompt,
|
||||
metadata,
|
||||
environmentDetails,
|
||||
filesReadByRoo,
|
||||
cwd,
|
||||
rooIgnoreController,
|
||||
} = options
|
||||
TelemetryService.instance.captureContextCondensed(
|
||||
taskId,
|
||||
isAutomaticTrigger ?? false,
|
||||
|
|
@ -289,7 +302,7 @@ export async function summarizeConversation(
|
|||
{ type: "text", text: `## Conversation Summary\n${summary}` },
|
||||
]
|
||||
|
||||
// Add command blocks as a separate text block if present
|
||||
// Add command blocks (active workflows) in their own system-reminder block if present
|
||||
if (commandBlocks) {
|
||||
summaryContent.push({
|
||||
type: "text",
|
||||
|
|
@ -301,6 +314,30 @@ ${commandBlocks}
|
|||
})
|
||||
}
|
||||
|
||||
// Generate and add folded file context (smart code folding) if file paths are provided
|
||||
// Each file gets its own <system-reminder> 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -612,16 +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,
|
||||
true, // isAutomaticTrigger
|
||||
undefined, // customCondensingPrompt
|
||||
undefined, // metadata
|
||||
undefined, // environmentDetails
|
||||
)
|
||||
isAutomaticTrigger: true,
|
||||
})
|
||||
|
||||
// Verify the result contains the summary information
|
||||
expect(result).toMatchObject({
|
||||
|
|
@ -787,16 +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,
|
||||
true, // isAutomaticTrigger
|
||||
undefined, // customCondensingPrompt
|
||||
undefined, // metadata
|
||||
undefined, // environmentDetails
|
||||
)
|
||||
isAutomaticTrigger: true,
|
||||
})
|
||||
|
||||
// Verify the result contains the summary information
|
||||
expect(result).toMatchObject({
|
||||
|
|
@ -854,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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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
|
||||
|
|
@ -222,6 +223,12 @@ export type ContextManagementOptions = {
|
|||
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 & {
|
||||
|
|
@ -252,6 +259,9 @@ export async function manageContext({
|
|||
currentProfileId,
|
||||
metadata,
|
||||
environmentDetails,
|
||||
filesReadByRoo,
|
||||
cwd,
|
||||
rooIgnoreController,
|
||||
}: ContextManagementOptions): Promise<ContextManagementResult> {
|
||||
let error: string | undefined
|
||||
let errorDetails: string | undefined
|
||||
|
|
@ -297,16 +307,19 @@ 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,
|
||||
true, // automatic trigger
|
||||
isAutomaticTrigger: true,
|
||||
customCondensingPrompt,
|
||||
metadata,
|
||||
environmentDetails,
|
||||
)
|
||||
filesReadByRoo,
|
||||
cwd,
|
||||
rooIgnoreController,
|
||||
})
|
||||
if (result.error) {
|
||||
error = result.error
|
||||
errorDetails = result.errorDetails
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
||||
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<string>()
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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 { formatLanguage } from "../../shared/language"
|
||||
import { defaultModeSlug, getFullModeDetails } from "../../shared/modes"
|
||||
|
|
@ -26,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.
|
||||
|
|
@ -112,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}`
|
||||
}
|
||||
}
|
||||
|
|
@ -144,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}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
|
||||
// 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<string>): Promise<void> {
|
||||
try {
|
||||
// Access globalStoragePath and taskId through the task reference
|
||||
const task = this.task as any // Access private member
|
||||
const globalStoragePath = task.globalStoragePath
|
||||
const taskId = task.taskId
|
||||
|
||||
if (!globalStoragePath || !taskId) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
|
||||
const outputDir = path.join(taskDir, "command-output")
|
||||
await OutputInterceptor.cleanupByIds(outputDir, validIds)
|
||||
} catch (error) {
|
||||
// Silently fail - cleanup is best-effort
|
||||
console.debug("[MessageManager] Artifact cleanup skipped:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import fetchInstructions from "./fetch_instructions"
|
|||
import generateImage from "./generate_image"
|
||||
import listFiles from "./list_files"
|
||||
import newTask from "./new_task"
|
||||
import readCommandOutput from "./read_command_output"
|
||||
import { createReadFileTool, type ReadFileToolOptions } from "./read_file"
|
||||
import runSlashCommand from "./run_slash_command"
|
||||
import searchAndReplace from "./search_and_replace"
|
||||
|
|
@ -65,6 +66,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
|
|||
generateImage,
|
||||
listFiles,
|
||||
newTask,
|
||||
readCommandOutput,
|
||||
createReadFileTool(readFileOptions),
|
||||
runSlashCommand,
|
||||
searchAndReplace,
|
||||
|
|
|
|||
81
src/core/prompts/tools/native-tools/read_command_output.ts
Normal file
81
src/core/prompts/tools/native-tools/read_command_output.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type OpenAI from "openai"
|
||||
|
||||
/**
|
||||
* Native tool definition for read_command_output.
|
||||
*
|
||||
* This tool allows the LLM to retrieve full command output that was truncated
|
||||
* during execute_command. When command output exceeds the preview threshold,
|
||||
* the full output is persisted to disk and an artifact_id is provided. The
|
||||
* LLM can then use this tool to read the full content or search within it.
|
||||
*/
|
||||
|
||||
const READ_COMMAND_OUTPUT_DESCRIPTION = `Retrieve the full output from a command that was truncated in execute_command. Use this tool when:
|
||||
1. The execute_command result shows "[OUTPUT TRUNCATED - Full output saved to artifact: cmd-XXXX.txt]"
|
||||
2. You need to see more of the command output beyond the preview
|
||||
3. You want to search for specific content in large command output
|
||||
|
||||
The tool supports two modes:
|
||||
- **Read mode**: Read output starting from a byte offset with optional limit
|
||||
- **Search mode**: Filter lines matching a regex or literal pattern (like grep)
|
||||
|
||||
Parameters:
|
||||
- artifact_id: (required) The artifact filename from the truncated output message (e.g., "cmd-1706119234567.txt")
|
||||
- search: (optional) Pattern to filter lines. Supports regex or literal strings. Case-insensitive. **Omit this parameter entirely if you don't need to filter - do not pass null or empty string.**
|
||||
- offset: (optional) Byte offset to start reading from. Default: 0. Use for pagination.
|
||||
- limit: (optional) Maximum bytes to return. Default: 40KB.
|
||||
|
||||
Example: Reading truncated command output
|
||||
{ "artifact_id": "cmd-1706119234567.txt" }
|
||||
|
||||
Example: Reading with pagination (after first 40KB)
|
||||
{ "artifact_id": "cmd-1706119234567.txt", "offset": 40960 }
|
||||
|
||||
Example: Searching for errors in build output
|
||||
{ "artifact_id": "cmd-1706119234567.txt", "search": "error|failed|Error" }
|
||||
|
||||
Example: Finding specific test failures
|
||||
{ "artifact_id": "cmd-1706119234567.txt", "search": "FAIL" }`
|
||||
|
||||
const ARTIFACT_ID_DESCRIPTION = `The artifact filename from the truncated command output (e.g., "cmd-1706119234567.txt")`
|
||||
|
||||
const SEARCH_DESCRIPTION = `Optional regex or literal pattern to filter lines (case-insensitive, like grep). Omit this parameter if not searching - do not pass null or empty string.`
|
||||
|
||||
const OFFSET_DESCRIPTION = `Byte offset to start reading from (default: 0, for pagination)`
|
||||
|
||||
const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 40KB)`
|
||||
|
||||
export default {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_command_output",
|
||||
description: READ_COMMAND_OUTPUT_DESCRIPTION,
|
||||
// Note: strict mode is intentionally disabled for this tool.
|
||||
// With strict: true, OpenAI requires ALL properties to be in the 'required' array,
|
||||
// which forces the LLM to always provide explicit values (even null) for optional params.
|
||||
// This creates verbose tool calls and poor UX. By disabling strict mode, the LLM can
|
||||
// omit optional parameters entirely, making the tool easier to use.
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
artifact_id: {
|
||||
type: "string",
|
||||
description: ARTIFACT_ID_DESCRIPTION,
|
||||
},
|
||||
search: {
|
||||
type: "string",
|
||||
description: SEARCH_DESCRIPTION,
|
||||
},
|
||||
offset: {
|
||||
type: "number",
|
||||
description: OFFSET_DESCRIPTION,
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: LIMIT_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
required: ["artifact_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
} satisfies OpenAI.Chat.ChatCompletionTool
|
||||
|
|
@ -84,11 +84,13 @@ import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
|
|||
import { findToolName } from "../../integrations/misc/export-markdown"
|
||||
import { RooTerminalProcess } from "../../integrations/terminal/types"
|
||||
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
|
||||
import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor"
|
||||
|
||||
// utils
|
||||
import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../../shared/cost"
|
||||
import { getWorkspacePath } from "../../utils/path"
|
||||
import { sanitizeToolUseId } from "../../utils/tool-id"
|
||||
import { getTaskDirectoryPath } from "../../utils/storage"
|
||||
|
||||
// prompts
|
||||
import { formatResponse } from "../prompts/responses"
|
||||
|
|
@ -353,6 +355,20 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = []
|
||||
userMessageContentReady = false
|
||||
|
||||
/**
|
||||
* Flag indicating whether the assistant message for the current streaming session
|
||||
* has been saved to API conversation history.
|
||||
*
|
||||
* This is critical for parallel tool calling: tools should NOT execute until
|
||||
* the assistant message is saved. Otherwise, if a tool like `new_task` triggers
|
||||
* `flushPendingToolResultsToHistory()`, the user message with tool_results would
|
||||
* appear BEFORE the assistant message with tool_uses, causing API errors.
|
||||
*
|
||||
* Reset to `false` at the start of each API request.
|
||||
* Set to `true` after the assistant message is saved in `recursivelyMakeClineRequests`.
|
||||
*/
|
||||
assistantMessageSavedToHistory = false
|
||||
|
||||
/**
|
||||
* Push a tool_result block to userMessageContent, preventing duplicates.
|
||||
* Duplicate tool_use_ids cause API errors.
|
||||
|
|
@ -1061,6 +1077,36 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Wait for the assistant message to be saved to API history first.
|
||||
// Without this, tool_result blocks would appear BEFORE tool_use blocks in the
|
||||
// conversation history, causing API errors like:
|
||||
// "unexpected `tool_use_id` found in `tool_result` blocks"
|
||||
//
|
||||
// This can happen when parallel tools are called (e.g., update_todo_list + new_task).
|
||||
// Tools execute during streaming via presentAssistantMessage, BEFORE the assistant
|
||||
// message is saved. When new_task triggers delegation, it calls this method to
|
||||
// flush pending results - but the assistant message hasn't been saved yet.
|
||||
//
|
||||
// The assistantMessageSavedToHistory flag is:
|
||||
// - Reset to false at the start of each API request
|
||||
// - Set to true after the assistant message is saved in recursivelyMakeClineRequests
|
||||
if (!this.assistantMessageSavedToHistory) {
|
||||
await pWaitFor(() => this.assistantMessageSavedToHistory || this.abort, {
|
||||
interval: 50,
|
||||
timeout: 30_000, // 30 second timeout as safety net
|
||||
}).catch(() => {
|
||||
// If timeout or abort, log and proceed anyway to avoid hanging
|
||||
console.warn(
|
||||
`[Task#${this.taskId}] flushPendingToolResultsToHistory: timed out waiting for assistant message to be saved`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// If task was aborted while waiting, don't flush
|
||||
if (this.abort) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save the user message with tool_result blocks
|
||||
const userMessage: Anthropic.MessageParam = {
|
||||
role: "user",
|
||||
|
|
@ -1573,6 +1619,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
}
|
||||
|
||||
private async getFilesReadByRooSafely(context: string): Promise<string[] | undefined> {
|
||||
try {
|
||||
return await this.fileContextTracker.getFilesReadByRoo()
|
||||
} catch (error) {
|
||||
console.error(`[Task#${context}] Failed to get files read by Roo:`, error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
public async condenseContext(): Promise<void> {
|
||||
// CRITICAL: Flush any pending tool results before condensing
|
||||
// to ensure tool_use/tool_result pairs are complete in history
|
||||
|
|
@ -1623,6 +1678,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// Generate environment details to include in the condensed summary
|
||||
const environmentDetails = await getEnvironmentDetails(this, true)
|
||||
|
||||
const filesReadByRoo = await this.getFilesReadByRooSafely("condenseContext")
|
||||
|
||||
const {
|
||||
messages,
|
||||
summary,
|
||||
|
|
@ -1631,16 +1688,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
error,
|
||||
errorDetails,
|
||||
condenseId,
|
||||
} = await summarizeConversation(
|
||||
this.apiConversationHistory,
|
||||
this.api, // Main API handler (fallback)
|
||||
systemPrompt, // Default summarization prompt (fallback)
|
||||
this.taskId,
|
||||
false, // manual trigger
|
||||
customCondensingPrompt, // User's custom prompt
|
||||
metadata, // Pass metadata with tools
|
||||
environmentDetails, // Include environment details in summary
|
||||
)
|
||||
} = await summarizeConversation({
|
||||
messages: this.apiConversationHistory,
|
||||
apiHandler: this.api,
|
||||
systemPrompt,
|
||||
taskId: this.taskId,
|
||||
isAutomaticTrigger: false,
|
||||
customCondensingPrompt,
|
||||
metadata,
|
||||
environmentDetails,
|
||||
filesReadByRoo,
|
||||
cwd: this.cwd,
|
||||
rooIgnoreController: this.rooIgnoreController,
|
||||
})
|
||||
if (error) {
|
||||
await this.say(
|
||||
"condense_context_error",
|
||||
|
|
@ -2252,6 +2312,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
console.error("Error releasing terminals:", error)
|
||||
}
|
||||
|
||||
// Cleanup command output artifacts
|
||||
getTaskDirectoryPath(this.globalStoragePath, this.taskId)
|
||||
.then((taskDir) => {
|
||||
const outputDir = path.join(taskDir, "command-output")
|
||||
return OutputInterceptor.cleanup(outputDir)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error cleaning up command output artifacts:", error)
|
||||
})
|
||||
|
||||
try {
|
||||
this.urlContentFetcher.closeBrowser()
|
||||
} catch (error) {
|
||||
|
|
@ -2681,6 +2751,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.userMessageContentReady = false
|
||||
this.didRejectTool = false
|
||||
this.didAlreadyUseTool = false
|
||||
this.assistantMessageSavedToHistory = false
|
||||
// Reset tool failure flag for each new assistant turn - this ensures that tool failures
|
||||
// only prevent attempt_completion within the same assistant message, not across turns
|
||||
// (e.g., if a tool fails, then user sends a message saying "just complete anyway")
|
||||
|
|
@ -3462,6 +3533,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
{ role: "assistant", content: assistantContent },
|
||||
reasoningMessage || undefined,
|
||||
)
|
||||
this.assistantMessageSavedToHistory = true
|
||||
|
||||
TelemetryService.instance.captureConversationMessage(this.taskId, "assistant")
|
||||
}
|
||||
|
|
@ -4039,6 +4111,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
? await getEnvironmentDetails(this, true)
|
||||
: undefined
|
||||
|
||||
// Get files read by Roo for code folding - only when context management will run
|
||||
const contextMgmtFilesReadByRoo =
|
||||
contextManagementWillRun && autoCondenseContext
|
||||
? await this.getFilesReadByRooSafely("attemptApiRequest")
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const truncateResult = await manageContext({
|
||||
messages: this.apiConversationHistory,
|
||||
|
|
@ -4055,6 +4133,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
currentProfileId,
|
||||
metadata: contextMgmtMetadata,
|
||||
environmentDetails: contextMgmtEnvironmentDetails,
|
||||
filesReadByRoo: contextMgmtFilesReadByRoo,
|
||||
cwd: this.cwd,
|
||||
rooIgnoreController: this.rooIgnoreController,
|
||||
})
|
||||
if (truncateResult.messages !== this.apiConversationHistory) {
|
||||
await this.overwriteApiConversationHistory(truncateResult.messages)
|
||||
|
|
|
|||
|
|
@ -38,8 +38,12 @@ vi.mock("fs/promises", async (importOriginal) => {
|
|||
}
|
||||
})
|
||||
|
||||
const { mockPWaitFor } = vi.hoisted(() => {
|
||||
return { mockPWaitFor: vi.fn().mockImplementation(async () => Promise.resolve()) }
|
||||
})
|
||||
|
||||
vi.mock("p-wait-for", () => ({
|
||||
default: vi.fn().mockImplementation(async () => Promise.resolve()),
|
||||
default: mockPWaitFor,
|
||||
}))
|
||||
|
||||
vi.mock("vscode", () => {
|
||||
|
|
@ -344,4 +348,99 @@ describe("flushPendingToolResultsToHistory", () => {
|
|||
expect((task.apiConversationHistory[0] as any).ts).toBeGreaterThanOrEqual(beforeTs)
|
||||
expect((task.apiConversationHistory[0] as any).ts).toBeLessThanOrEqual(afterTs)
|
||||
})
|
||||
|
||||
it("should skip waiting for assistantMessageSavedToHistory when flag is already true", async () => {
|
||||
const task = new Task({
|
||||
provider: mockProvider,
|
||||
apiConfiguration: mockApiConfig,
|
||||
task: "test task",
|
||||
startTask: false,
|
||||
})
|
||||
|
||||
// Set flag to true (assistant message already saved)
|
||||
task.assistantMessageSavedToHistory = true
|
||||
|
||||
// Set up pending tool result
|
||||
task.userMessageContent = [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-skip-wait",
|
||||
content: "Result when flag is true",
|
||||
},
|
||||
]
|
||||
|
||||
// Clear mock call history
|
||||
mockPWaitFor.mockClear()
|
||||
|
||||
await task.flushPendingToolResultsToHistory()
|
||||
|
||||
// Should not have called pWaitFor since flag was already true
|
||||
expect(mockPWaitFor).not.toHaveBeenCalled()
|
||||
|
||||
// Should still save the message
|
||||
expect(task.apiConversationHistory.length).toBe(1)
|
||||
expect((task.apiConversationHistory[0].content as any[])[0].tool_use_id).toBe("tool-skip-wait")
|
||||
})
|
||||
|
||||
it("should wait for assistantMessageSavedToHistory when flag is false", async () => {
|
||||
const task = new Task({
|
||||
provider: mockProvider,
|
||||
apiConfiguration: mockApiConfig,
|
||||
task: "test task",
|
||||
startTask: false,
|
||||
})
|
||||
|
||||
// Flag is false by default - assistant message not yet saved
|
||||
expect(task.assistantMessageSavedToHistory).toBe(false)
|
||||
|
||||
// Set up pending tool result
|
||||
task.userMessageContent = [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-wait",
|
||||
content: "Result when flag is false",
|
||||
},
|
||||
]
|
||||
|
||||
// Clear mock call history
|
||||
mockPWaitFor.mockClear()
|
||||
|
||||
await task.flushPendingToolResultsToHistory()
|
||||
|
||||
// Should have called pWaitFor since flag was false
|
||||
expect(mockPWaitFor).toHaveBeenCalled()
|
||||
|
||||
// Should still save the message (mock resolves immediately)
|
||||
expect(task.apiConversationHistory.length).toBe(1)
|
||||
})
|
||||
|
||||
it("should not flush when task is aborted during wait", async () => {
|
||||
const task = new Task({
|
||||
provider: mockProvider,
|
||||
apiConfiguration: mockApiConfig,
|
||||
task: "test task",
|
||||
startTask: false,
|
||||
})
|
||||
|
||||
// Flag is false - will need to wait
|
||||
task.assistantMessageSavedToHistory = false
|
||||
|
||||
// Set up pending tool result
|
||||
task.userMessageContent = [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-aborted",
|
||||
content: "Should not be saved",
|
||||
},
|
||||
]
|
||||
|
||||
// Set abort flag - this will cause the condition in pWaitFor to return true
|
||||
// AND will cause early return after the wait
|
||||
task.abort = true
|
||||
|
||||
await task.flushPendingToolResultsToHistory()
|
||||
|
||||
// Should not have saved anything since task was aborted
|
||||
expect(task.apiConversationHistory.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import * as vscode from "vscode"
|
|||
|
||||
import delay from "delay"
|
||||
|
||||
import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types"
|
||||
import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE, PersistedCommandOutput } from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import { Task } from "../task/Task"
|
||||
|
|
@ -15,8 +15,10 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization"
|
|||
import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types"
|
||||
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
|
||||
import { Terminal } from "../../integrations/terminal/Terminal"
|
||||
import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor"
|
||||
import { Package } from "../../shared/package"
|
||||
import { t } from "../../i18n"
|
||||
import { getTaskDirectoryPath } from "../../utils/storage"
|
||||
import { BaseTool, ToolCallbacks } from "./BaseTool"
|
||||
|
||||
class ShellIntegrationError extends Error {}
|
||||
|
|
@ -62,11 +64,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
|
|||
const provider = await task.providerRef.deref()
|
||||
const providerState = await provider?.getState()
|
||||
|
||||
const {
|
||||
terminalOutputLineLimit = 500,
|
||||
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
||||
terminalShellIntegrationDisabled = true,
|
||||
} = providerState ?? {}
|
||||
const { terminalShellIntegrationDisabled = true } = providerState ?? {}
|
||||
|
||||
// Get command execution timeout from VSCode configuration (in seconds)
|
||||
const commandExecutionTimeoutSeconds = vscode.workspace
|
||||
|
|
@ -91,8 +89,6 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
|
|||
command: unescapedCommand,
|
||||
customCwd,
|
||||
terminalShellIntegrationDisabled,
|
||||
terminalOutputLineLimit,
|
||||
terminalOutputCharacterLimit,
|
||||
commandExecutionTimeout,
|
||||
}
|
||||
|
||||
|
|
@ -146,8 +142,6 @@ export type ExecuteCommandOptions = {
|
|||
command: string
|
||||
customCwd?: string
|
||||
terminalShellIntegrationDisabled?: boolean
|
||||
terminalOutputLineLimit?: number
|
||||
terminalOutputCharacterLimit?: number
|
||||
commandExecutionTimeout?: number
|
||||
}
|
||||
|
||||
|
|
@ -158,8 +152,6 @@ export async function executeCommandInTerminal(
|
|||
command,
|
||||
customCwd,
|
||||
terminalShellIntegrationDisabled = true,
|
||||
terminalOutputLineLimit = 500,
|
||||
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
||||
commandExecutionTimeout = 0,
|
||||
}: ExecuteCommandOptions,
|
||||
): Promise<[boolean, ToolResponse]> {
|
||||
|
|
@ -185,6 +177,7 @@ export async function executeCommandInTerminal(
|
|||
let runInBackground = false
|
||||
let completed = false
|
||||
let result: string = ""
|
||||
let persistedResult: PersistedCommandOutput | undefined
|
||||
let exitDetails: ExitCodeDetails | undefined
|
||||
let shellIntegrationError: string | undefined
|
||||
let hasAskedForCommandOutput = false
|
||||
|
|
@ -192,15 +185,55 @@ export async function executeCommandInTerminal(
|
|||
const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode"
|
||||
const provider = await task.providerRef.deref()
|
||||
|
||||
// Get global storage path for persisted output artifacts
|
||||
const globalStoragePath = provider?.context?.globalStorageUri?.fsPath
|
||||
let interceptor: OutputInterceptor | undefined
|
||||
|
||||
// Create OutputInterceptor if we have storage available
|
||||
if (globalStoragePath) {
|
||||
const taskDir = await getTaskDirectoryPath(globalStoragePath, task.taskId)
|
||||
const storageDir = path.join(taskDir, "command-output")
|
||||
const providerState = await provider?.getState()
|
||||
const terminalOutputPreviewSize =
|
||||
providerState?.terminalOutputPreviewSize ?? DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE
|
||||
|
||||
interceptor = new OutputInterceptor({
|
||||
executionId,
|
||||
taskId: task.taskId,
|
||||
command,
|
||||
storageDir,
|
||||
previewSize: terminalOutputPreviewSize,
|
||||
})
|
||||
}
|
||||
|
||||
let accumulatedOutput = ""
|
||||
// Bound accumulated output buffer size to prevent unbounded memory growth for long-running commands.
|
||||
// The interceptor preserves full output; this buffer is only for UI display (100KB limit).
|
||||
const maxAccumulatedOutputSize = 100_000
|
||||
|
||||
// Track when onCompleted callback finishes to avoid race condition.
|
||||
// The callback is async but Terminal/ExecaTerminal don't await it, so we track completion
|
||||
// explicitly to ensure persistedResult is set before we use it.
|
||||
let onCompletedPromise: Promise<void> | undefined
|
||||
let resolveOnCompleted: (() => void) | undefined
|
||||
onCompletedPromise = new Promise((resolve) => {
|
||||
resolveOnCompleted = resolve
|
||||
})
|
||||
|
||||
const callbacks: RooTerminalCallbacks = {
|
||||
onLine: async (lines: string, process: RooTerminalProcess) => {
|
||||
accumulatedOutput += lines
|
||||
const compressedOutput = Terminal.compressTerminalOutput(
|
||||
accumulatedOutput,
|
||||
terminalOutputLineLimit,
|
||||
terminalOutputCharacterLimit,
|
||||
)
|
||||
|
||||
// Trim accumulated output to prevent unbounded memory growth
|
||||
if (accumulatedOutput.length > maxAccumulatedOutputSize) {
|
||||
accumulatedOutput = accumulatedOutput.slice(-maxAccumulatedOutputSize)
|
||||
}
|
||||
|
||||
// Write to interceptor for persisted output
|
||||
interceptor?.write(lines)
|
||||
|
||||
// Continue sending compressed output to webview for UI display (unchanged behavior)
|
||||
const compressedOutput = Terminal.compressTerminalOutput(accumulatedOutput)
|
||||
const status: CommandExecutionStatus = { executionId, status: "output", output: compressedOutput }
|
||||
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
|
||||
|
||||
|
|
@ -223,15 +256,24 @@ export async function executeCommandInTerminal(
|
|||
// Silently handle ask errors (e.g., "Current ask promise was ignored")
|
||||
}
|
||||
},
|
||||
onCompleted: (output: string | undefined) => {
|
||||
result = Terminal.compressTerminalOutput(
|
||||
output ?? "",
|
||||
terminalOutputLineLimit,
|
||||
terminalOutputCharacterLimit,
|
||||
)
|
||||
onCompleted: async (output: string | undefined) => {
|
||||
try {
|
||||
// Finalize interceptor and get persisted result.
|
||||
// We await finalize() to ensure the artifact file is fully flushed
|
||||
// before we advertise the artifact_id to the LLM.
|
||||
if (interceptor) {
|
||||
persistedResult = await interceptor.finalize()
|
||||
}
|
||||
|
||||
task.say("command_output", result)
|
||||
completed = true
|
||||
// Continue using compressed output for UI display
|
||||
result = Terminal.compressTerminalOutput(output ?? "")
|
||||
|
||||
task.say("command_output", result)
|
||||
completed = true
|
||||
} finally {
|
||||
// Signal that onCompleted has finished, so the main code can safely use persistedResult
|
||||
resolveOnCompleted?.()
|
||||
}
|
||||
},
|
||||
onShellExecutionStarted: (pid: number | undefined) => {
|
||||
const status: CommandExecutionStatus = { executionId, status: "started", pid, command }
|
||||
|
|
@ -321,6 +363,13 @@ export async function executeCommandInTerminal(
|
|||
// grouping command_output messages despite any gaps anyways).
|
||||
await delay(50)
|
||||
|
||||
// Wait for onCompleted callback to finish if shell execution completed.
|
||||
// This ensures persistedResult is set before we try to use it, fixing the race
|
||||
// condition where exitDetails is set (sync) before the async onCompleted finishes.
|
||||
if (exitDetails && onCompletedPromise) {
|
||||
await onCompletedPromise
|
||||
}
|
||||
|
||||
if (message) {
|
||||
const { text, images } = message
|
||||
await task.say("user_feedback", text, images)
|
||||
|
|
@ -337,6 +386,14 @@ export async function executeCommandInTerminal(
|
|||
),
|
||||
]
|
||||
} else if (completed || exitDetails) {
|
||||
const currentWorkingDir = terminal.getCurrentWorkingDirectory().toPosix()
|
||||
|
||||
// Use persisted output format when output was truncated and spilled to disk
|
||||
if (persistedResult?.truncated) {
|
||||
return [false, formatPersistedOutput(persistedResult, exitDetails, currentWorkingDir)]
|
||||
}
|
||||
|
||||
// Use inline format for small outputs (original behavior with exit status)
|
||||
let exitStatus: string = ""
|
||||
|
||||
if (exitDetails !== undefined) {
|
||||
|
|
@ -361,9 +418,10 @@ export async function executeCommandInTerminal(
|
|||
exitStatus = `Exit code: <undefined, notify user>`
|
||||
}
|
||||
|
||||
let workingDirInfo = ` within working directory '${terminal.getCurrentWorkingDirectory().toPosix()}'`
|
||||
|
||||
return [false, `Command executed in terminal ${workingDirInfo}. ${exitStatus}\nOutput:\n${result}`]
|
||||
return [
|
||||
false,
|
||||
`Command executed in terminal within working directory '${currentWorkingDir}'. ${exitStatus}\nOutput:\n${result}`,
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
false,
|
||||
|
|
@ -376,4 +434,69 @@ export async function executeCommandInTerminal(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format exit status from ExitCodeDetails
|
||||
*/
|
||||
function formatExitStatus(exitDetails: ExitCodeDetails | undefined): string {
|
||||
if (exitDetails === undefined) {
|
||||
return "Exit code: <undefined, notify user>"
|
||||
}
|
||||
|
||||
if (exitDetails.signalName) {
|
||||
let status = `Process terminated by signal ${exitDetails.signalName}`
|
||||
if (exitDetails.coreDumpPossible) {
|
||||
status += " - core dump possible"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
if (exitDetails.exitCode === undefined) {
|
||||
return "Exit code: <undefined, notify user>"
|
||||
}
|
||||
|
||||
let status = ""
|
||||
if (exitDetails.exitCode !== 0) {
|
||||
status += "Command execution was not successful, inspect the cause and adjust as needed.\n"
|
||||
}
|
||||
status += `Exit code: ${exitDetails.exitCode}`
|
||||
return status
|
||||
}
|
||||
|
||||
/**
|
||||
* Format persisted output result for tool response when output was truncated
|
||||
*/
|
||||
function formatPersistedOutput(
|
||||
result: PersistedCommandOutput,
|
||||
exitDetails: ExitCodeDetails | undefined,
|
||||
workingDir: string,
|
||||
): string {
|
||||
const exitStatus = formatExitStatus(exitDetails)
|
||||
const sizeStr = formatBytes(result.totalBytes)
|
||||
const artifactId = result.artifactPath ? path.basename(result.artifactPath) : ""
|
||||
|
||||
return [
|
||||
`Command executed in '${workingDir}'. ${exitStatus}`,
|
||||
"",
|
||||
`Output (${sizeStr}) persisted. Artifact ID: ${artifactId}`,
|
||||
"",
|
||||
"Preview:",
|
||||
result.preview,
|
||||
"",
|
||||
"Use read_command_output tool to view full output if needed.",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable string
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes}B`
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)}KB`
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
export const executeCommandTool = new ExecuteCommandTool()
|
||||
|
|
|
|||
484
src/core/tools/ReadCommandOutputTool.ts
Normal file
484
src/core/tools/ReadCommandOutputTool.ts
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
import { Task } from "../task/Task"
|
||||
import { getTaskDirectoryPath } from "../../utils/storage"
|
||||
|
||||
import { BaseTool, ToolCallbacks } from "./BaseTool"
|
||||
|
||||
/** Default byte limit for read operations (40KB) */
|
||||
const DEFAULT_LIMIT = 40 * 1024 // 40KB default limit
|
||||
|
||||
/**
|
||||
* Parameters accepted by the read_command_output tool.
|
||||
*/
|
||||
interface ReadCommandOutputParams {
|
||||
/**
|
||||
* The artifact file identifier (e.g., "cmd-1706119234567.txt").
|
||||
* This is provided in the execute_command output when truncation occurs.
|
||||
*/
|
||||
artifact_id: string
|
||||
/**
|
||||
* Optional search pattern (regex or literal string) to filter lines.
|
||||
* When provided, only lines matching the pattern are returned.
|
||||
*/
|
||||
search?: string
|
||||
/**
|
||||
* Byte offset to start reading from (default: 0).
|
||||
* Used for paginating through large outputs.
|
||||
*/
|
||||
offset?: number
|
||||
/**
|
||||
* Maximum bytes to return (default: 32KB).
|
||||
* Limits the amount of data returned in a single request.
|
||||
*/
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadCommandOutputTool allows the LLM to retrieve full command output that was truncated.
|
||||
*
|
||||
* When `execute_command` produces output exceeding the preview threshold, the full output
|
||||
* is persisted to disk by the `OutputInterceptor`. This tool enables the LLM to:
|
||||
*
|
||||
* 1. **Read full output**: Retrieve the complete command output beyond the preview
|
||||
* 2. **Search output**: Filter lines matching a pattern (like grep)
|
||||
* 3. **Paginate**: Read large outputs in chunks using offset/limit
|
||||
*
|
||||
* ## Storage Location
|
||||
*
|
||||
* Artifacts are stored outside the workspace in the task directory:
|
||||
* `globalStoragePath/tasks/{taskId}/command-output/cmd-{executionId}.txt`
|
||||
*
|
||||
* ## Security
|
||||
*
|
||||
* The tool validates artifact_id format to prevent path traversal attacks.
|
||||
* Only files matching `cmd-{digits}.txt` pattern are accessible.
|
||||
*
|
||||
* ## Usage Flow
|
||||
*
|
||||
* 1. LLM calls `execute_command` which runs a command
|
||||
* 2. If output is large, response includes `artifact_id` and truncation notice
|
||||
* 3. LLM calls `read_command_output` with the artifact_id to get more content
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage - read from beginning
|
||||
* await readCommandOutputTool.execute({
|
||||
* artifact_id: "cmd-1706119234567.txt"
|
||||
* }, task, callbacks);
|
||||
*
|
||||
* // Search for specific content
|
||||
* await readCommandOutputTool.execute({
|
||||
* artifact_id: "cmd-1706119234567.txt",
|
||||
* search: "error|failed"
|
||||
* }, task, callbacks);
|
||||
*
|
||||
* // Paginate through large output
|
||||
* await readCommandOutputTool.execute({
|
||||
* artifact_id: "cmd-1706119234567.txt",
|
||||
* offset: 32768, // Start after first 32KB
|
||||
* limit: 32768 // Read next 32KB
|
||||
* }, task, callbacks);
|
||||
* ```
|
||||
*/
|
||||
export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
|
||||
readonly name = "read_command_output" as const
|
||||
|
||||
/**
|
||||
* Execute the read_command_output tool.
|
||||
*
|
||||
* Reads persisted command output from disk, supporting both full reads and
|
||||
* search-based filtering. Results include line numbers for easy reference.
|
||||
*
|
||||
* @param params - The tool parameters including artifact_id and optional search/pagination
|
||||
* @param task - The current task instance for error reporting and state management
|
||||
* @param callbacks - Callbacks for pushing tool results
|
||||
*/
|
||||
async execute(params: ReadCommandOutputParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
const { pushToolResult } = callbacks
|
||||
const { artifact_id, search, offset = 0, limit = DEFAULT_LIMIT } = params
|
||||
|
||||
// Validate required parameters
|
||||
if (!artifact_id) {
|
||||
task.consecutiveMistakeCount++
|
||||
task.recordToolError("read_command_output")
|
||||
task.didToolFailInCurrentTurn = true
|
||||
const errorMsg = await task.sayAndCreateMissingParamError("read_command_output", "artifact_id")
|
||||
pushToolResult(`Error: ${errorMsg}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate artifact_id format to prevent path traversal
|
||||
if (!this.isValidArtifactId(artifact_id)) {
|
||||
task.consecutiveMistakeCount++
|
||||
task.recordToolError("read_command_output")
|
||||
task.didToolFailInCurrentTurn = true
|
||||
const errorMsg = `Invalid artifact_id format: "${artifact_id}". Expected format: cmd-{timestamp}.txt (e.g., "cmd-1706119234567.txt")`
|
||||
await task.say("error", errorMsg)
|
||||
pushToolResult(`Error: ${errorMsg}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the task directory path
|
||||
const provider = await task.providerRef.deref()
|
||||
const globalStoragePath = provider?.context?.globalStorageUri?.fsPath
|
||||
|
||||
if (!globalStoragePath) {
|
||||
const errorMsg = "Unable to access command output storage. Global storage path is not available."
|
||||
await task.say("error", errorMsg)
|
||||
pushToolResult(`Error: ${errorMsg}`)
|
||||
return
|
||||
}
|
||||
|
||||
const taskDir = await getTaskDirectoryPath(globalStoragePath, task.taskId)
|
||||
const artifactPath = path.join(taskDir, "command-output", artifact_id)
|
||||
|
||||
// Check if artifact exists
|
||||
try {
|
||||
await fs.access(artifactPath)
|
||||
} catch {
|
||||
const errorMsg = `Artifact not found: "${artifact_id}". Please verify the artifact_id from the command output message. Available artifacts are created when command output exceeds the preview size.`
|
||||
await task.say("error", errorMsg)
|
||||
task.didToolFailInCurrentTurn = true
|
||||
pushToolResult(`Error: ${errorMsg}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Get file stats for metadata
|
||||
const stats = await fs.stat(artifactPath)
|
||||
const totalSize = stats.size
|
||||
|
||||
// Validate offset
|
||||
if (offset < 0 || offset >= totalSize) {
|
||||
const errorMsg = `Invalid offset: ${offset}. File size is ${totalSize} bytes. Offset must be between 0 and ${totalSize - 1}.`
|
||||
await task.say("error", errorMsg)
|
||||
pushToolResult(`Error: ${errorMsg}`)
|
||||
return
|
||||
}
|
||||
|
||||
let result: string
|
||||
let readStart = 0
|
||||
let readEnd = 0
|
||||
let matchCount: number | undefined
|
||||
|
||||
if (search) {
|
||||
// Search mode: filter lines matching the pattern
|
||||
const searchResult = await this.searchInArtifact(artifactPath, search, totalSize, limit)
|
||||
result = searchResult.content
|
||||
matchCount = searchResult.matchCount
|
||||
// For search, we're scanning the whole file
|
||||
readStart = 0
|
||||
readEnd = totalSize
|
||||
} else {
|
||||
// Normal read mode with offset/limit
|
||||
result = await this.readArtifact(artifactPath, offset, limit, totalSize)
|
||||
// Calculate actual read range
|
||||
readStart = offset
|
||||
readEnd = Math.min(offset + limit, totalSize)
|
||||
}
|
||||
|
||||
// Report to UI that we read command output
|
||||
await task.say(
|
||||
"tool",
|
||||
JSON.stringify({
|
||||
tool: "readCommandOutput",
|
||||
readStart,
|
||||
readEnd,
|
||||
totalBytes: totalSize,
|
||||
...(search && { searchPattern: search, matchCount }),
|
||||
}),
|
||||
)
|
||||
|
||||
task.consecutiveMistakeCount = 0
|
||||
pushToolResult(result)
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
await task.say("error", `Error reading command output: ${errorMsg}`)
|
||||
task.didToolFailInCurrentTurn = true
|
||||
pushToolResult(`Error reading command output: ${errorMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate artifact_id format to prevent path traversal attacks.
|
||||
*
|
||||
* Only accepts IDs matching the pattern `cmd-{digits}.txt` which are
|
||||
* generated by the OutputInterceptor. This prevents malicious paths
|
||||
* like `../../../etc/passwd` from being used.
|
||||
*
|
||||
* @param artifactId - The artifact ID to validate
|
||||
* @returns `true` if the format is valid, `false` otherwise
|
||||
* @private
|
||||
*/
|
||||
private isValidArtifactId(artifactId: string): boolean {
|
||||
// Only allow alphanumeric, hyphens, underscores, and dots
|
||||
// Must match pattern cmd-{digits}.txt
|
||||
const validPattern = /^cmd-\d+\.txt$/
|
||||
return validPattern.test(artifactId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read artifact content with offset and limit, adding line numbers.
|
||||
*
|
||||
* Performs efficient partial file reads using file handles and positional
|
||||
* reads. Line numbers are calculated by counting newlines in the portion
|
||||
* of the file before the offset.
|
||||
*
|
||||
* @param artifactPath - Absolute path to the artifact file
|
||||
* @param offset - Byte offset to start reading from
|
||||
* @param limit - Maximum bytes to read
|
||||
* @param totalSize - Total size of the file in bytes
|
||||
* @returns Formatted output with header metadata and line-numbered content
|
||||
* @private
|
||||
*/
|
||||
private async readArtifact(
|
||||
artifactPath: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
totalSize: number,
|
||||
): Promise<string> {
|
||||
const fileHandle = await fs.open(artifactPath, "r")
|
||||
|
||||
try {
|
||||
const buffer = Buffer.alloc(Math.min(limit, totalSize - offset))
|
||||
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, offset)
|
||||
const content = buffer.slice(0, bytesRead).toString("utf8")
|
||||
|
||||
// Calculate line numbers based on offset using chunked reading to avoid large allocations
|
||||
let startLineNumber = 1
|
||||
if (offset > 0) {
|
||||
startLineNumber = await this.countNewlinesBeforeOffset(fileHandle, offset)
|
||||
}
|
||||
|
||||
const endOffset = offset + bytesRead
|
||||
const truncated = endOffset < totalSize
|
||||
const artifactId = path.basename(artifactPath)
|
||||
|
||||
// Add line numbers to content
|
||||
const numberedContent = this.addLineNumbers(content, startLineNumber)
|
||||
|
||||
const header = [
|
||||
`[Command Output: ${artifactId}]`,
|
||||
`Total size: ${this.formatBytes(totalSize)} | Showing bytes ${offset}-${endOffset} | ${truncated ? "TRUNCATED" : "COMPLETE"}`,
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
return header + numberedContent
|
||||
} finally {
|
||||
await fileHandle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search artifact content for lines matching a pattern using chunked streaming.
|
||||
*
|
||||
* Performs grep-like searching through the artifact file using bounded memory.
|
||||
* Instead of loading the entire file into memory, this reads in fixed-size chunks
|
||||
* and processes lines as they are encountered. This keeps memory usage predictable
|
||||
* even for very large command outputs (e.g., 100MB+ build logs).
|
||||
*
|
||||
* The pattern is treated as a case-insensitive regex. If the pattern is invalid
|
||||
* regex syntax, it's escaped and treated as a literal string.
|
||||
*
|
||||
* Results are limited by the byte limit to prevent excessive output.
|
||||
*
|
||||
* @param artifactPath - Absolute path to the artifact file
|
||||
* @param pattern - Search pattern (regex or literal string)
|
||||
* @param totalSize - Total size of the file in bytes (for display)
|
||||
* @param limit - Maximum bytes of matching content to return
|
||||
* @returns Formatted output with matching lines and their line numbers
|
||||
* @private
|
||||
*/
|
||||
private async searchInArtifact(
|
||||
artifactPath: string,
|
||||
pattern: string,
|
||||
totalSize: number,
|
||||
limit: number,
|
||||
): Promise<{ content: string; matchCount: number }> {
|
||||
const CHUNK_SIZE = 64 * 1024 // 64KB chunks for bounded memory
|
||||
|
||||
// Create case-insensitive regex for search
|
||||
let regex: RegExp
|
||||
try {
|
||||
regex = new RegExp(pattern, "i")
|
||||
} catch {
|
||||
// If invalid regex, treat as literal string
|
||||
regex = new RegExp(this.escapeRegExp(pattern), "i")
|
||||
}
|
||||
|
||||
const fileHandle = await fs.open(artifactPath, "r")
|
||||
const matches: Array<{ lineNumber: number; content: string }> = []
|
||||
let totalMatchBytes = 0
|
||||
let lineNumber = 0
|
||||
let partialLine = "" // Holds incomplete line from previous chunk
|
||||
let bytesRead = 0
|
||||
let hitLimit = false
|
||||
|
||||
try {
|
||||
while (bytesRead < totalSize && !hitLimit) {
|
||||
const chunkSize = Math.min(CHUNK_SIZE, totalSize - bytesRead)
|
||||
const buffer = Buffer.alloc(chunkSize)
|
||||
const result = await fileHandle.read(buffer, 0, chunkSize, bytesRead)
|
||||
|
||||
if (result.bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
const chunk = buffer.slice(0, result.bytesRead).toString("utf8")
|
||||
bytesRead += result.bytesRead
|
||||
|
||||
// Combine with partial line from previous chunk
|
||||
const combined = partialLine + chunk
|
||||
const lines = combined.split("\n")
|
||||
|
||||
// Last element may be incomplete (no trailing newline), save for next iteration
|
||||
partialLine = lines.pop() ?? ""
|
||||
|
||||
// Process complete lines
|
||||
for (const line of lines) {
|
||||
lineNumber++
|
||||
|
||||
if (regex.test(line)) {
|
||||
const lineBytes = Buffer.byteLength(line, "utf8")
|
||||
|
||||
// Stop if we've exceeded the byte limit
|
||||
if (totalMatchBytes + lineBytes > limit) {
|
||||
hitLimit = true
|
||||
break
|
||||
}
|
||||
|
||||
matches.push({ lineNumber, content: line })
|
||||
totalMatchBytes += lineBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining partial line at end of file
|
||||
if (!hitLimit && partialLine.length > 0) {
|
||||
lineNumber++
|
||||
if (regex.test(partialLine)) {
|
||||
const lineBytes = Buffer.byteLength(partialLine, "utf8")
|
||||
if (totalMatchBytes + lineBytes <= limit) {
|
||||
matches.push({ lineNumber, content: partialLine })
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await fileHandle.close()
|
||||
}
|
||||
|
||||
const artifactId = path.basename(artifactPath)
|
||||
|
||||
if (matches.length === 0) {
|
||||
const content = [
|
||||
`[Command Output: ${artifactId}] (search: "${pattern}")`,
|
||||
`Total size: ${this.formatBytes(totalSize)}`,
|
||||
"",
|
||||
"No matches found for the search pattern.",
|
||||
].join("\n")
|
||||
return { content, matchCount: 0 }
|
||||
}
|
||||
|
||||
// Format matches with line numbers
|
||||
const matchedLines = matches.map((m) => `${String(m.lineNumber).padStart(5)} | ${m.content}`).join("\n")
|
||||
|
||||
const content = [
|
||||
`[Command Output: ${artifactId}] (search: "${pattern}")`,
|
||||
`Total matches: ${matches.length} | Showing first ${matches.length}`,
|
||||
"",
|
||||
matchedLines,
|
||||
].join("\n")
|
||||
return { content, matchCount: matches.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add line numbers to content for easier reference.
|
||||
*
|
||||
* Each line is prefixed with its line number, right-padded to align
|
||||
* all line numbers in the output.
|
||||
*
|
||||
* @param content - The text content to add line numbers to
|
||||
* @param startLine - The line number for the first line
|
||||
* @returns Content with line numbers prefixed to each line
|
||||
* @private
|
||||
*/
|
||||
private addLineNumbers(content: string, startLine: number): string {
|
||||
const lines = content.split("\n")
|
||||
const maxLineNum = startLine + lines.length - 1
|
||||
const padding = String(maxLineNum).length
|
||||
|
||||
return lines.map((line, index) => `${String(startLine + index).padStart(padding)} | ${line}`).join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a byte count to a human-readable string.
|
||||
*
|
||||
* @param bytes - The byte count to format
|
||||
* @returns Human-readable string (e.g., "1.5KB", "2.3MB")
|
||||
* @private
|
||||
*/
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} bytes`
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)}KB`
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters in a string for literal matching.
|
||||
*
|
||||
* @param string - The string to escape
|
||||
* @returns The escaped string safe for use in a RegExp constructor
|
||||
* @private
|
||||
*/
|
||||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
/**
|
||||
* Count newlines before a given byte offset using fixed-size chunks.
|
||||
*
|
||||
* This avoids allocating a buffer of size `offset` which could be huge
|
||||
* for large files. Instead, we read in 64KB chunks and count newlines.
|
||||
*
|
||||
* @param fileHandle - Open file handle for reading
|
||||
* @param offset - The byte offset to count newlines up to
|
||||
* @returns The line number at the given offset (1-indexed)
|
||||
* @private
|
||||
*/
|
||||
private async countNewlinesBeforeOffset(fileHandle: fs.FileHandle, offset: number): Promise<number> {
|
||||
const CHUNK_SIZE = 64 * 1024 // 64KB chunks
|
||||
let newlineCount = 0
|
||||
let bytesRead = 0
|
||||
|
||||
while (bytesRead < offset) {
|
||||
const chunkSize = Math.min(CHUNK_SIZE, offset - bytesRead)
|
||||
const buffer = Buffer.alloc(chunkSize)
|
||||
const result = await fileHandle.read(buffer, 0, chunkSize, bytesRead)
|
||||
|
||||
if (result.bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
// Count newlines in this chunk
|
||||
for (let i = 0; i < result.bytesRead; i++) {
|
||||
if (buffer[i] === 0x0a) {
|
||||
// '\n'
|
||||
newlineCount++
|
||||
}
|
||||
}
|
||||
|
||||
bytesRead += result.bytesRead
|
||||
}
|
||||
|
||||
return newlineCount + 1 // Line numbers are 1-indexed
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance of the ReadCommandOutputTool */
|
||||
export const readCommandOutputTool = new ReadCommandOutputTool()
|
||||
579
src/core/tools/__tests__/ReadCommandOutputTool.test.ts
Normal file
579
src/core/tools/__tests__/ReadCommandOutputTool.test.ts
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
|
||||
import { ReadCommandOutputTool } from "../ReadCommandOutputTool"
|
||||
import { Task } from "../../task/Task"
|
||||
|
||||
// Mock filesystem operations
|
||||
vi.mock("fs/promises", () => ({
|
||||
default: {
|
||||
access: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
open: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
access: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
open: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock getTaskDirectoryPath
|
||||
vi.mock("../../../utils/storage", () => ({
|
||||
getTaskDirectoryPath: vi.fn((globalStoragePath: string, taskId: string) => {
|
||||
return path.join(globalStoragePath, "tasks", taskId)
|
||||
}),
|
||||
}))
|
||||
|
||||
describe("ReadCommandOutputTool", () => {
|
||||
let tool: ReadCommandOutputTool
|
||||
let mockTask: any
|
||||
let mockCallbacks: any
|
||||
let mockFileHandle: any
|
||||
let globalStoragePath: string
|
||||
let taskId: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
tool = new ReadCommandOutputTool()
|
||||
globalStoragePath = "/mock/global/storage"
|
||||
taskId = "task-123"
|
||||
|
||||
// Mock task object
|
||||
mockTask = {
|
||||
taskId,
|
||||
consecutiveMistakeCount: 0,
|
||||
didToolFailInCurrentTurn: false,
|
||||
say: vi.fn().mockResolvedValue(undefined),
|
||||
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter"),
|
||||
recordToolError: vi.fn(),
|
||||
providerRef: {
|
||||
deref: vi.fn().mockResolvedValue({
|
||||
context: {
|
||||
globalStorageUri: {
|
||||
fsPath: globalStoragePath,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
// Mock callbacks
|
||||
mockCallbacks = {
|
||||
pushToolResult: vi.fn(),
|
||||
}
|
||||
|
||||
// Mock file handle
|
||||
mockFileHandle = {
|
||||
read: vi.fn(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
// Default mocks
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: 1000 } as any)
|
||||
vi.mocked(fs.open).mockResolvedValue(mockFileHandle as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("Basic read functionality", () => {
|
||||
it("should read artifact file correctly", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Line 1\nLine 2\nLine 3\n"
|
||||
const buffer = Buffer.from(content)
|
||||
|
||||
mockFileHandle.read.mockImplementation((buf: Buffer) => {
|
||||
buffer.copy(buf)
|
||||
return Promise.resolve({ bytesRead: buffer.length })
|
||||
})
|
||||
|
||||
await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks)
|
||||
|
||||
expect(fs.access).toHaveBeenCalledWith(
|
||||
path.join(globalStoragePath, "tasks", taskId, "command-output", artifactId),
|
||||
)
|
||||
expect(mockCallbacks.pushToolResult).toHaveBeenCalled()
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain("Line 1")
|
||||
expect(result).toContain("Line 2")
|
||||
expect(result).toContain("Line 3")
|
||||
})
|
||||
|
||||
it("should return content with line numbers", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "First line\nSecond line\nThird line\n"
|
||||
const buffer = Buffer.from(content)
|
||||
|
||||
mockFileHandle.read.mockImplementation((buf: Buffer) => {
|
||||
buffer.copy(buf)
|
||||
return Promise.resolve({ bytesRead: buffer.length })
|
||||
})
|
||||
|
||||
await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toMatch(/1 \| First line/)
|
||||
expect(result).toMatch(/2 \| Second line/)
|
||||
expect(result).toMatch(/3 \| Third line/)
|
||||
})
|
||||
|
||||
it("should include size metadata in output", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Test output"
|
||||
const fileSize = 5000
|
||||
const buffer = Buffer.from(content)
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
mockFileHandle.read.mockImplementation((buf: Buffer) => {
|
||||
buffer.copy(buf)
|
||||
return Promise.resolve({ bytesRead: buffer.length })
|
||||
})
|
||||
|
||||
await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain(`[Command Output: ${artifactId}]`)
|
||||
expect(result).toContain("Total size:")
|
||||
expect(result).toMatch(/\d+(\.\d+)?(bytes|KB|MB)/)
|
||||
})
|
||||
|
||||
it("should close file handle after reading", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Test"
|
||||
const buffer = Buffer.from(content)
|
||||
|
||||
mockFileHandle.read.mockImplementation((buf: Buffer) => {
|
||||
buffer.copy(buf)
|
||||
return Promise.resolve({ bytesRead: buffer.length })
|
||||
})
|
||||
|
||||
await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks)
|
||||
|
||||
expect(mockFileHandle.close).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Pagination (offset/limit)", () => {
|
||||
it("should use default limit of 40KB", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const largeContent = "x".repeat(50 * 1024) // 50KB
|
||||
const fileSize = Buffer.byteLength(largeContent, "utf8")
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
|
||||
// Mock read to return only up to default limit (40KB)
|
||||
mockFileHandle.read.mockImplementation((buf: Buffer) => {
|
||||
const defaultLimit = 40 * 1024
|
||||
const bytesToRead = Math.min(buf.length, defaultLimit)
|
||||
buf.write(largeContent.slice(0, bytesToRead))
|
||||
return Promise.resolve({ bytesRead: bytesToRead })
|
||||
})
|
||||
|
||||
await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain("TRUNCATED")
|
||||
})
|
||||
|
||||
it("should start reading from custom offset", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "0123456789ABCDEFGHIJ"
|
||||
const offset = 10
|
||||
const fileSize = Buffer.byteLength(content, "utf8")
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
|
||||
// Mock first read for offset calculation (returns content before offset)
|
||||
// Mock second read for actual content
|
||||
let readCallCount = 0
|
||||
mockFileHandle.read.mockImplementation(
|
||||
(buf: Buffer, bufOffset: number, length: number, position: number | null) => {
|
||||
readCallCount++
|
||||
if (position === 0) {
|
||||
// First read: prefix for line number calculation
|
||||
const prefixContent = content.slice(0, offset)
|
||||
buf.write(prefixContent)
|
||||
return Promise.resolve({ bytesRead: prefixContent.length })
|
||||
} else {
|
||||
// Second read: actual content from offset
|
||||
const actualContent = content.slice(offset)
|
||||
buf.write(actualContent)
|
||||
return Promise.resolve({ bytesRead: actualContent.length })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, offset }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain(`Showing bytes ${offset}-`)
|
||||
expect(mockFileHandle.read).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should restrict output size with custom limit", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const largeContent = "x".repeat(10000)
|
||||
const customLimit = 1000
|
||||
const fileSize = Buffer.byteLength(largeContent, "utf8")
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
|
||||
mockFileHandle.read.mockImplementation((buf: Buffer) => {
|
||||
const bytesToRead = Math.min(buf.length, customLimit)
|
||||
buf.write(largeContent.slice(0, bytesToRead))
|
||||
return Promise.resolve({ bytesRead: bytesToRead })
|
||||
})
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, limit: customLimit }, mockTask, mockCallbacks)
|
||||
|
||||
expect(mockCallbacks.pushToolResult).toHaveBeenCalled()
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain("TRUNCATED")
|
||||
})
|
||||
|
||||
it("should show TRUNCATED when more content exists", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const fileSize = 10000
|
||||
const limit = 5000
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
|
||||
mockFileHandle.read.mockImplementation((buf: Buffer) => {
|
||||
const content = "x".repeat(limit)
|
||||
buf.write(content)
|
||||
return Promise.resolve({ bytesRead: limit })
|
||||
})
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, limit }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain("TRUNCATED")
|
||||
})
|
||||
|
||||
it("should show COMPLETE when all content is returned", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Small content"
|
||||
const fileSize = Buffer.byteLength(content, "utf8")
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
|
||||
mockFileHandle.read.mockImplementation((buf: Buffer) => {
|
||||
buf.write(content)
|
||||
return Promise.resolve({ bytesRead: fileSize })
|
||||
})
|
||||
|
||||
await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain("COMPLETE")
|
||||
expect(result).not.toContain("TRUNCATED")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Search filtering", () => {
|
||||
// Helper to setup file handle mock for search (which now uses streaming)
|
||||
const setupSearchMock = (content: string) => {
|
||||
const buffer = Buffer.from(content)
|
||||
const fileSize = buffer.length
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
|
||||
// Mock streaming read - return entire content in one chunk (simulates small file)
|
||||
mockFileHandle.read.mockImplementation(
|
||||
(buf: Buffer, bufOffset: number, length: number, position: number | null) => {
|
||||
const pos = position ?? 0
|
||||
if (pos >= fileSize) {
|
||||
return Promise.resolve({ bytesRead: 0 })
|
||||
}
|
||||
const bytesToRead = Math.min(length, fileSize - pos)
|
||||
buffer.copy(buf, 0, pos, pos + bytesToRead)
|
||||
return Promise.resolve({ bytesRead: bytesToRead })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
it("should filter lines matching pattern", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Line 1: error occurred\nLine 2: success\nLine 3: error found\nLine 4: complete\n"
|
||||
|
||||
setupSearchMock(content)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, search: "error" }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain("error occurred")
|
||||
expect(result).toContain("error found")
|
||||
expect(result).not.toContain("success")
|
||||
expect(result).not.toContain("complete")
|
||||
})
|
||||
|
||||
it("should use case-insensitive matching", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "ERROR: Something bad\nwarning: minor issue\nERROR: Another problem\n"
|
||||
|
||||
setupSearchMock(content)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, search: "error" }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain("ERROR: Something bad")
|
||||
expect(result).toContain("ERROR: Another problem")
|
||||
})
|
||||
|
||||
it("should show match count and line numbers", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Line 1\nError on line 2\nLine 3\nError on line 4\n"
|
||||
|
||||
setupSearchMock(content)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, search: "Error" }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain("Total matches: 2")
|
||||
expect(result).toMatch(/2 \|.*Error on line 2/)
|
||||
expect(result).toMatch(/4 \|.*Error on line 4/)
|
||||
})
|
||||
|
||||
it("should handle empty search results gracefully", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Line 1\nLine 2\nLine 3\n"
|
||||
|
||||
setupSearchMock(content)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, search: "NOTFOUND" }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain("No matches found for the search pattern")
|
||||
})
|
||||
|
||||
it("should handle regex patterns in search", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "test123\ntest456\nabc789\ntest000\n"
|
||||
|
||||
setupSearchMock(content)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, search: "test\\d+" }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain("test123")
|
||||
expect(result).toContain("test456")
|
||||
expect(result).toContain("test000")
|
||||
expect(result).not.toContain("abc789")
|
||||
})
|
||||
|
||||
it("should handle invalid regex patterns by treating as literal", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Line with [brackets]\nLine without\n"
|
||||
|
||||
setupSearchMock(content)
|
||||
|
||||
// Invalid regex but valid as literal string
|
||||
await tool.execute({ artifact_id: artifactId, search: "[" }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain("[brackets]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("should return error for non-existent artifact", async () => {
|
||||
const artifactId = "cmd-9999999999.txt"
|
||||
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"))
|
||||
|
||||
await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks)
|
||||
|
||||
expect(mockTask.didToolFailInCurrentTurn).toBe(true)
|
||||
expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("not found"))
|
||||
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Error: Artifact not found"),
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject invalid artifact_id with path traversal attempt", async () => {
|
||||
const invalidIds = [
|
||||
"../../../etc/passwd",
|
||||
"..\\..\\..\\windows\\system32\\config",
|
||||
"cmd-123/../other.txt",
|
||||
"cmd-<script>alert()</script>.txt",
|
||||
"cmd-.txt",
|
||||
"invalid-format.txt",
|
||||
]
|
||||
|
||||
for (const invalidId of invalidIds) {
|
||||
vi.clearAllMocks()
|
||||
mockTask.consecutiveMistakeCount = 0
|
||||
mockTask.didToolFailInCurrentTurn = false
|
||||
|
||||
await tool.execute({ artifact_id: invalidId }, mockTask, mockCallbacks)
|
||||
|
||||
expect(mockTask.consecutiveMistakeCount).toBeGreaterThan(0)
|
||||
expect(mockTask.didToolFailInCurrentTurn).toBe(true)
|
||||
expect(mockTask.say).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Invalid artifact_id format"),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("should accept valid artifact_id format", async () => {
|
||||
const validId = "cmd-1706119234567.txt"
|
||||
const content = "Test"
|
||||
const buffer = Buffer.from(content)
|
||||
|
||||
mockFileHandle.read.mockImplementation((buf: Buffer) => {
|
||||
buffer.copy(buf)
|
||||
return Promise.resolve({ bytesRead: buffer.length })
|
||||
})
|
||||
|
||||
await tool.execute({ artifact_id: validId }, mockTask, mockCallbacks)
|
||||
|
||||
expect(mockTask.consecutiveMistakeCount).toBe(0)
|
||||
expect(mockTask.didToolFailInCurrentTurn).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle invalid offset gracefully", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const fileSize = 1000
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
|
||||
await tool.execute(
|
||||
{ artifact_id: artifactId, offset: 2000 }, // Offset beyond file size
|
||||
mockTask,
|
||||
mockCallbacks,
|
||||
)
|
||||
|
||||
expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Invalid offset"))
|
||||
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error: Invalid offset"))
|
||||
})
|
||||
|
||||
it("should handle negative offset", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const fileSize = 1000
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, offset: -10 }, mockTask, mockCallbacks)
|
||||
|
||||
expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Invalid offset"))
|
||||
})
|
||||
|
||||
it("should handle missing artifact_id parameter", async () => {
|
||||
await tool.execute({ artifact_id: "" }, mockTask, mockCallbacks)
|
||||
|
||||
expect(mockTask.consecutiveMistakeCount).toBeGreaterThan(0)
|
||||
expect(mockTask.recordToolError).toHaveBeenCalledWith("read_command_output")
|
||||
expect(mockTask.didToolFailInCurrentTurn).toBe(true)
|
||||
expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("read_command_output", "artifact_id")
|
||||
})
|
||||
|
||||
it("should handle missing global storage path", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
|
||||
mockTask.providerRef.deref.mockResolvedValue({
|
||||
context: {
|
||||
globalStorageUri: null,
|
||||
},
|
||||
})
|
||||
|
||||
await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks)
|
||||
|
||||
expect(mockTask.say).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Global storage path is not available"),
|
||||
)
|
||||
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error"))
|
||||
})
|
||||
|
||||
it("should handle file read errors", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
|
||||
mockFileHandle.read.mockRejectedValue(new Error("Read error"))
|
||||
|
||||
await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks)
|
||||
|
||||
expect(mockTask.didToolFailInCurrentTurn).toBe(true)
|
||||
expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading command output"))
|
||||
})
|
||||
|
||||
it("should ensure file handle is closed even on error", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
|
||||
mockFileHandle.read.mockRejectedValue(new Error("Read error"))
|
||||
|
||||
await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks)
|
||||
|
||||
expect(mockFileHandle.close).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Byte formatting", () => {
|
||||
it("should format bytes correctly", async () => {
|
||||
const testCases = [
|
||||
{ size: 500, expected: "bytes" },
|
||||
{ size: 1024, expected: "1.0KB" },
|
||||
{ size: 2048, expected: "2.0KB" },
|
||||
{ size: 1024 * 1024, expected: "1.0MB" },
|
||||
{ size: 2.5 * 1024 * 1024, expected: "2.5MB" },
|
||||
]
|
||||
|
||||
for (const { size, expected } of testCases) {
|
||||
vi.clearAllMocks()
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "x"
|
||||
const buffer = Buffer.from(content)
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size } as any)
|
||||
mockFileHandle.read.mockImplementation((buf: Buffer) => {
|
||||
buffer.copy(buf)
|
||||
return Promise.resolve({ bytesRead: buffer.length })
|
||||
})
|
||||
|
||||
await tool.execute({ artifact_id: artifactId }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
expect(result).toContain(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Line number calculation", () => {
|
||||
it("should calculate correct starting line number for offset", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n"
|
||||
const offset = 14 // After "Line 1\nLine 2\n"
|
||||
const fileSize = Buffer.byteLength(content, "utf8")
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
|
||||
let readCallCount = 0
|
||||
mockFileHandle.read.mockImplementation(
|
||||
(buf: Buffer, bufOffset: number, length: number, position: number | null) => {
|
||||
readCallCount++
|
||||
if (position === 0) {
|
||||
// Read prefix for line counting
|
||||
const prefix = content.slice(0, offset)
|
||||
buf.write(prefix)
|
||||
return Promise.resolve({ bytesRead: prefix.length })
|
||||
} else {
|
||||
// Read actual content from offset
|
||||
const actualContent = content.slice(offset)
|
||||
buf.write(actualContent)
|
||||
return Promise.resolve({ bytesRead: actualContent.length })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, offset }, mockTask, mockCallbacks)
|
||||
|
||||
const result = mockCallbacks.pushToolResult.mock.calls[0][0]
|
||||
// Should start at line 3 since we skipped 2 newlines
|
||||
expect(result).toMatch(/3 \|/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -40,7 +40,6 @@ describe("executeCommand", () => {
|
|||
mockProvider = {
|
||||
postMessageToWebview: vitest.fn(),
|
||||
getState: vitest.fn().mockResolvedValue({
|
||||
terminalOutputLineLimit: 500,
|
||||
terminalShellIntegrationDisabled: false,
|
||||
}),
|
||||
}
|
||||
|
|
@ -100,7 +99,6 @@ describe("executeCommand", () => {
|
|||
executionId: "test-123",
|
||||
command: "echo test",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
@ -141,7 +139,6 @@ describe("executeCommand", () => {
|
|||
executionId: "test-123",
|
||||
command: "echo test",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
@ -174,7 +171,6 @@ describe("executeCommand", () => {
|
|||
executionId: "test-123",
|
||||
command: "echo test",
|
||||
terminalShellIntegrationDisabled: true, // Forces ExecaTerminal
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
@ -205,7 +201,6 @@ describe("executeCommand", () => {
|
|||
command: "echo test",
|
||||
customCwd,
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
@ -235,7 +230,6 @@ describe("executeCommand", () => {
|
|||
command: "echo test",
|
||||
customCwd: relativeCwd,
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
@ -258,7 +252,6 @@ describe("executeCommand", () => {
|
|||
command: "echo test",
|
||||
customCwd: nonExistentCwd,
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
@ -285,7 +278,6 @@ describe("executeCommand", () => {
|
|||
executionId: "test-123",
|
||||
command: "echo test",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
@ -308,7 +300,6 @@ describe("executeCommand", () => {
|
|||
executionId: "test-123",
|
||||
command: "echo test",
|
||||
terminalShellIntegrationDisabled: true,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
@ -334,7 +325,6 @@ describe("executeCommand", () => {
|
|||
executionId: "test-123",
|
||||
command: "echo success",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
@ -360,7 +350,6 @@ describe("executeCommand", () => {
|
|||
executionId: "test-123",
|
||||
command: "exit 1",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
@ -394,7 +383,6 @@ describe("executeCommand", () => {
|
|||
executionId: "test-123",
|
||||
command: "long-running-command",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
@ -436,7 +424,6 @@ describe("executeCommand", () => {
|
|||
executionId: "test-123",
|
||||
command: "cd src && pwd",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import {
|
|||
RooCodeEventName,
|
||||
requestyDefaultModelId,
|
||||
openRouterDefaultModelId,
|
||||
DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
||||
DEFAULT_WRITE_DELAY_MS,
|
||||
ORGANIZATION_ALLOW_ALL,
|
||||
DEFAULT_MODES,
|
||||
|
|
@ -159,7 +158,7 @@ export class ClineProvider
|
|||
|
||||
public isViewLaunched = false
|
||||
public settingsImportedAt?: number
|
||||
public readonly latestAnnouncementId = "jan-2026-v3.44.0-worktrees" // v3.44.0 Worktrees
|
||||
public readonly latestAnnouncementId = "jan-2026-v3.45.0-smart-code-folding" // v3.45.0 Smart Code Folding
|
||||
public readonly providerSettingsManager: ProviderSettingsManager
|
||||
public readonly customModesManager: CustomModesManager
|
||||
|
||||
|
|
@ -2016,8 +2015,6 @@ export class ClineProvider
|
|||
remoteBrowserEnabled,
|
||||
cachedChromeHostUrl,
|
||||
writeDelayMs,
|
||||
terminalOutputLineLimit,
|
||||
terminalOutputCharacterLimit,
|
||||
terminalShellIntegrationTimeout,
|
||||
terminalShellIntegrationDisabled,
|
||||
terminalCommandDelay,
|
||||
|
|
@ -2048,7 +2045,6 @@ export class ClineProvider
|
|||
maxReadFileLine,
|
||||
maxImageFileSize,
|
||||
maxTotalImageSize,
|
||||
terminalCompressProgressBar,
|
||||
historyPreviewCollapsed,
|
||||
reasoningBlockCollapsed,
|
||||
enterBehavior,
|
||||
|
|
@ -2157,8 +2153,6 @@ export class ClineProvider
|
|||
remoteBrowserEnabled: remoteBrowserEnabled ?? false,
|
||||
cachedChromeHostUrl: cachedChromeHostUrl,
|
||||
writeDelayMs: writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
|
||||
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
|
||||
terminalOutputCharacterLimit: terminalOutputCharacterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
||||
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
|
||||
terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? true,
|
||||
terminalCommandDelay: terminalCommandDelay ?? 0,
|
||||
|
|
@ -2196,7 +2190,6 @@ export class ClineProvider
|
|||
maxTotalImageSize: maxTotalImageSize ?? 20,
|
||||
maxConcurrentFileReads: maxConcurrentFileReads ?? 5,
|
||||
settingsImportedAt: this.settingsImportedAt,
|
||||
terminalCompressProgressBar: terminalCompressProgressBar ?? true,
|
||||
hasSystemPromptOverride,
|
||||
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
|
||||
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
|
||||
|
|
@ -2403,9 +2396,6 @@ export class ClineProvider
|
|||
remoteBrowserEnabled: stateValues.remoteBrowserEnabled ?? false,
|
||||
cachedChromeHostUrl: stateValues.cachedChromeHostUrl as string | undefined,
|
||||
writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
|
||||
terminalOutputLineLimit: stateValues.terminalOutputLineLimit ?? 500,
|
||||
terminalOutputCharacterLimit:
|
||||
stateValues.terminalOutputCharacterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
||||
terminalShellIntegrationTimeout:
|
||||
stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
|
||||
terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true,
|
||||
|
|
@ -2415,7 +2405,6 @@ export class ClineProvider
|
|||
terminalZshOhMy: stateValues.terminalZshOhMy ?? false,
|
||||
terminalZshP10k: stateValues.terminalZshP10k ?? false,
|
||||
terminalZdotdir: stateValues.terminalZdotdir ?? false,
|
||||
terminalCompressProgressBar: stateValues.terminalCompressProgressBar ?? true,
|
||||
mode: stateValues.mode ?? defaultModeSlug,
|
||||
language: stateValues.language ?? formatLanguage(vscode.env.language),
|
||||
mcpEnabled: stateValues.mcpEnabled ?? true,
|
||||
|
|
|
|||
|
|
@ -631,10 +631,6 @@ export const webviewMessageHandler = async (
|
|||
if (value !== undefined) {
|
||||
Terminal.setTerminalZdotdir(value as boolean)
|
||||
}
|
||||
} else if (key === "terminalCompressProgressBar") {
|
||||
if (value !== undefined) {
|
||||
Terminal.setCompressProgressBar(value as boolean)
|
||||
}
|
||||
} else if (key === "mcpEnabled") {
|
||||
newValue = value ?? true
|
||||
const mcpHub = provider.getMcpHub()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { truncateOutput, applyRunLengthEncoding, processBackspaces, processCarriageReturns } from "../misc/extract-text"
|
||||
import { DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types"
|
||||
import { truncateOutput, applyRunLengthEncoding } from "../misc/extract-text"
|
||||
|
||||
import type {
|
||||
RooTerminalProvider,
|
||||
|
|
@ -162,7 +161,6 @@ export abstract class BaseTerminal implements RooTerminal {
|
|||
private static terminalZshOhMy: boolean = false
|
||||
private static terminalZshP10k: boolean = false
|
||||
private static terminalZdotdir: boolean = false
|
||||
private static compressProgressBar: boolean = true
|
||||
|
||||
/**
|
||||
* Compresses terminal output by applying run-length encoding and truncating to line limit
|
||||
|
|
@ -266,24 +264,19 @@ export abstract class BaseTerminal implements RooTerminal {
|
|||
}
|
||||
|
||||
/**
|
||||
* Compresses terminal output by applying run-length encoding and truncating to line and character limits
|
||||
* Compresses terminal output by applying run-length encoding and truncating to reasonable limits.
|
||||
* Uses hardcoded defaults: 500 lines, 50K characters - these are UI display limits to prevent
|
||||
* memory issues, not LLM context limits (which are controlled by terminalOutputPreviewSize).
|
||||
* @param input The terminal output to compress
|
||||
* @param lineLimit Maximum number of lines to keep
|
||||
* @param characterLimit Optional maximum number of characters to keep (defaults to DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT)
|
||||
* @returns The compressed terminal output
|
||||
*/
|
||||
public static compressTerminalOutput(input: string, lineLimit: number, characterLimit?: number): string {
|
||||
let processedInput = input
|
||||
public static compressTerminalOutput(input: string): string {
|
||||
// Hardcoded UI display limits - these prevent unbounded memory growth
|
||||
// in the chat display, separate from the LLM context limits
|
||||
const LINE_LIMIT = 500
|
||||
const CHARACTER_LIMIT = 50_000
|
||||
|
||||
if (BaseTerminal.compressProgressBar) {
|
||||
processedInput = processCarriageReturns(processedInput)
|
||||
processedInput = processBackspaces(processedInput)
|
||||
}
|
||||
|
||||
// Default character limit to prevent context window explosion
|
||||
const effectiveCharLimit = characterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT
|
||||
|
||||
return truncateOutput(applyRunLengthEncoding(processedInput), lineLimit, effectiveCharLimit)
|
||||
return truncateOutput(applyRunLengthEncoding(input), LINE_LIMIT, CHARACTER_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -301,20 +294,4 @@ export abstract class BaseTerminal implements RooTerminal {
|
|||
public static getTerminalZdotdir(): boolean {
|
||||
return BaseTerminal.terminalZdotdir
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether to compress progress bar output by processing carriage returns
|
||||
* @param enabled Whether to enable progress bar compression
|
||||
*/
|
||||
public static setCompressProgressBar(enabled: boolean): void {
|
||||
BaseTerminal.compressProgressBar = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets whether progress bar compression is enabled
|
||||
* @returns Whether progress bar compression is enabled
|
||||
*/
|
||||
public static getCompressProgressBar(): boolean {
|
||||
return BaseTerminal.compressProgressBar
|
||||
}
|
||||
}
|
||||
|
|
|
|||
430
src/integrations/terminal/OutputInterceptor.ts
Normal file
430
src/integrations/terminal/OutputInterceptor.ts
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
|
||||
import { TerminalOutputPreviewSize, TERMINAL_PREVIEW_BYTES, PersistedCommandOutput } from "@roo-code/types"
|
||||
|
||||
/**
|
||||
* Configuration options for creating an OutputInterceptor instance.
|
||||
*/
|
||||
export interface OutputInterceptorOptions {
|
||||
/** Unique identifier for this command execution (typically a timestamp) */
|
||||
executionId: string
|
||||
/** ID of the task that initiated this command */
|
||||
taskId: string
|
||||
/** The command string being executed */
|
||||
command: string
|
||||
/** Directory path where command output artifacts will be stored */
|
||||
storageDir: string
|
||||
/** Size category for the preview buffer (small/medium/large) */
|
||||
previewSize: TerminalOutputPreviewSize
|
||||
}
|
||||
|
||||
/**
|
||||
* OutputInterceptor buffers terminal command output and spills to disk when threshold exceeded.
|
||||
*
|
||||
* This implements a "persisted output" pattern where large command outputs are saved to disk
|
||||
* files, with only a preview shown to the LLM. The LLM can then use the `read_command_output`
|
||||
* tool to retrieve full contents or search through the output.
|
||||
*
|
||||
* The interceptor uses a **head/tail buffer** strategy (inspired by Codex):
|
||||
* - 50% of the preview budget is allocated to the "head" (beginning of output)
|
||||
* - 50% of the preview budget is allocated to the "tail" (end of output)
|
||||
* - Middle content is dropped when output exceeds the preview threshold
|
||||
*
|
||||
* This approach ensures the LLM sees both:
|
||||
* - The beginning (command startup, environment info, early errors)
|
||||
* - The end (final results, exit codes, error summaries)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const interceptor = new OutputInterceptor({
|
||||
* executionId: Date.now().toString(),
|
||||
* taskId: 'task-123',
|
||||
* command: 'npm test',
|
||||
* storageDir: '/path/to/task/command-output',
|
||||
* previewSize: 'medium',
|
||||
* });
|
||||
*
|
||||
* // Write output chunks as they arrive
|
||||
* interceptor.write('Running tests...\n');
|
||||
* interceptor.write('Test 1 passed\n');
|
||||
*
|
||||
* // Finalize and get the result
|
||||
* const result = interceptor.finalize();
|
||||
* // result.preview contains head + [omitted] + tail for display
|
||||
* // result.artifactPath contains path to full output if truncated
|
||||
* ```
|
||||
*/
|
||||
export class OutputInterceptor {
|
||||
/** Buffer for the head (beginning) of output */
|
||||
private headBuffer: string = ""
|
||||
/** Buffer for the tail (end) of output - rolling buffer that drops front when full */
|
||||
private tailBuffer: string = ""
|
||||
/** Number of bytes currently in the head buffer */
|
||||
private headBytes: number = 0
|
||||
/** Number of bytes currently in the tail buffer */
|
||||
private tailBytes: number = 0
|
||||
/** Number of bytes omitted from the middle */
|
||||
private omittedBytes: number = 0
|
||||
|
||||
/**
|
||||
* Pending chunks accumulated before spilling to disk.
|
||||
* These contain ALL content (lossless) until we decide to spill.
|
||||
* Once spilled, this array is cleared and subsequent writes go directly to disk.
|
||||
*/
|
||||
private pendingChunks: string[] = []
|
||||
|
||||
private writeStream: fs.WriteStream | null = null
|
||||
private artifactPath: string
|
||||
private totalBytes: number = 0
|
||||
private spilledToDisk: boolean = false
|
||||
private readonly previewBytes: number
|
||||
/** Budget for the head buffer (50% of total preview) */
|
||||
private readonly headBudget: number
|
||||
/** Budget for the tail buffer (50% of total preview) */
|
||||
private readonly tailBudget: number
|
||||
|
||||
/**
|
||||
* Creates a new OutputInterceptor instance.
|
||||
*
|
||||
* @param options - Configuration options for the interceptor
|
||||
*/
|
||||
constructor(private readonly options: OutputInterceptorOptions) {
|
||||
this.previewBytes = TERMINAL_PREVIEW_BYTES[options.previewSize]
|
||||
this.headBudget = Math.floor(this.previewBytes / 2)
|
||||
this.tailBudget = this.previewBytes - this.headBudget
|
||||
this.artifactPath = path.join(options.storageDir, `cmd-${options.executionId}.txt`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a chunk of output to the interceptor.
|
||||
*
|
||||
* Output is first added to the head buffer until it's full (50% of preview budget).
|
||||
* Subsequent output goes to a rolling tail buffer that keeps the most recent content.
|
||||
*
|
||||
* If the total output exceeds the preview threshold, the interceptor spills to disk
|
||||
* for full output storage while maintaining head/tail buffers for the preview.
|
||||
*
|
||||
* @param chunk - The output string to write
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* interceptor.write('Building project...\n');
|
||||
* interceptor.write('Compiling 42 files\n');
|
||||
* ```
|
||||
*/
|
||||
write(chunk: string): void {
|
||||
const chunkBytes = Buffer.byteLength(chunk, "utf8")
|
||||
this.totalBytes += chunkBytes
|
||||
|
||||
// Always update the head/tail preview buffers
|
||||
this.addToPreviewBuffers(chunk)
|
||||
|
||||
// Handle disk spilling for full output preservation
|
||||
if (!this.spilledToDisk) {
|
||||
// Accumulate ALL chunks for lossless disk storage
|
||||
this.pendingChunks.push(chunk)
|
||||
|
||||
if (this.totalBytes > this.previewBytes) {
|
||||
this.spillToDisk()
|
||||
}
|
||||
} else {
|
||||
// Already spilling - write directly to disk
|
||||
this.writeStream?.write(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a chunk to the head/tail preview buffers using 50/50 split strategy.
|
||||
*
|
||||
* Fill head first until budget exhausted, then maintain a rolling tail buffer.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private addToPreviewBuffers(chunk: string): void {
|
||||
let remaining = chunk
|
||||
let remainingBytes = Buffer.byteLength(chunk, "utf8")
|
||||
|
||||
// First, fill the head buffer if there's room
|
||||
if (this.headBytes < this.headBudget) {
|
||||
const headRoom = this.headBudget - this.headBytes
|
||||
if (remainingBytes <= headRoom) {
|
||||
// Entire chunk fits in head
|
||||
this.headBuffer += remaining
|
||||
this.headBytes += remainingBytes
|
||||
return
|
||||
}
|
||||
// Split: part goes to head, rest goes to tail
|
||||
const headPortion = this.sliceByBytes(remaining, headRoom)
|
||||
this.headBuffer += headPortion
|
||||
this.headBytes += headRoom
|
||||
remaining = remaining.slice(headPortion.length)
|
||||
remainingBytes = Buffer.byteLength(remaining, "utf8")
|
||||
}
|
||||
|
||||
// Add remainder to tail buffer
|
||||
this.addToTailBuffer(remaining, remainingBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add content to the rolling tail buffer, dropping old content as needed.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private addToTailBuffer(chunk: string, chunkBytes: number): void {
|
||||
if (this.tailBudget === 0) {
|
||||
this.omittedBytes += chunkBytes
|
||||
return
|
||||
}
|
||||
|
||||
// If this single chunk is larger than the tail budget, keep only the last tailBudget bytes
|
||||
if (chunkBytes >= this.tailBudget) {
|
||||
const dropped = this.tailBytes + (chunkBytes - this.tailBudget)
|
||||
this.omittedBytes += dropped
|
||||
this.tailBuffer = this.sliceByBytesFromEnd(chunk, this.tailBudget)
|
||||
this.tailBytes = this.tailBudget
|
||||
return
|
||||
}
|
||||
|
||||
// Append to tail
|
||||
this.tailBuffer += chunk
|
||||
this.tailBytes += chunkBytes
|
||||
|
||||
// Trim from front if over budget
|
||||
this.trimTailToFit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim the tail buffer from the front to fit within the tail budget.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private trimTailToFit(): void {
|
||||
while (this.tailBytes > this.tailBudget && this.tailBuffer.length > 0) {
|
||||
const excess = this.tailBytes - this.tailBudget
|
||||
// Remove characters from the front until we're under budget
|
||||
// We need to be careful with multi-byte characters
|
||||
let removed = 0
|
||||
let removeChars = 0
|
||||
while (removed < excess && removeChars < this.tailBuffer.length) {
|
||||
const charBytes = Buffer.byteLength(this.tailBuffer[removeChars], "utf8")
|
||||
removed += charBytes
|
||||
removeChars++
|
||||
}
|
||||
this.omittedBytes += removed
|
||||
this.tailBytes -= removed
|
||||
this.tailBuffer = this.tailBuffer.slice(removeChars)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a string to get approximately the first N bytes (UTF-8).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private sliceByBytes(str: string, maxBytes: number): string {
|
||||
let bytes = 0
|
||||
let i = 0
|
||||
while (i < str.length && bytes < maxBytes) {
|
||||
const charBytes = Buffer.byteLength(str[i], "utf8")
|
||||
if (bytes + charBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
bytes += charBytes
|
||||
i++
|
||||
}
|
||||
return str.slice(0, i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a string to get approximately the last N bytes (UTF-8).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private sliceByBytesFromEnd(str: string, maxBytes: number): string {
|
||||
let bytes = 0
|
||||
let i = str.length - 1
|
||||
while (i >= 0 && bytes < maxBytes) {
|
||||
const charBytes = Buffer.byteLength(str[i], "utf8")
|
||||
if (bytes + charBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
bytes += charBytes
|
||||
i--
|
||||
}
|
||||
return str.slice(i + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spill buffered content to disk and switch to streaming mode.
|
||||
*
|
||||
* This is called automatically when the buffer exceeds the preview threshold.
|
||||
* Creates the storage directory if it doesn't exist, writes the current buffer
|
||||
* to the artifact file, and prepares for streaming subsequent output.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private spillToDisk(): void {
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(this.artifactPath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
this.writeStream = fs.createWriteStream(this.artifactPath)
|
||||
|
||||
// Write ALL pending chunks to disk for lossless storage.
|
||||
// This ensures no content is lost, even if the preview buffers have dropped middle content.
|
||||
for (const chunk of this.pendingChunks) {
|
||||
this.writeStream.write(chunk)
|
||||
}
|
||||
|
||||
// Clear pending chunks to free memory - subsequent writes go directly to disk
|
||||
this.pendingChunks = []
|
||||
|
||||
this.spilledToDisk = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize the interceptor and return the persisted output result.
|
||||
*
|
||||
* Closes any open file streams and waits for them to fully flush before returning.
|
||||
* This ensures the artifact file is completely written and ready for reading.
|
||||
*
|
||||
* Returns a summary object containing:
|
||||
* - A preview of the output (head + [omitted indicator] + tail)
|
||||
* - The total byte count of all output
|
||||
* - The path to the full output file (if truncated)
|
||||
* - A flag indicating whether the output was truncated
|
||||
*
|
||||
* @returns The persisted command output summary
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await interceptor.finalize();
|
||||
* console.log(`Preview: ${result.preview}`);
|
||||
* console.log(`Total bytes: ${result.totalBytes}`);
|
||||
* if (result.truncated) {
|
||||
* console.log(`Full output at: ${result.artifactPath}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async finalize(): Promise<PersistedCommandOutput> {
|
||||
// Close write stream if open and wait for it to fully flush.
|
||||
// This ensures the artifact is completely written before we advertise the artifact_id.
|
||||
if (this.writeStream) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.writeStream!.end(() => resolve())
|
||||
this.writeStream!.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
// Prepare preview: head + [omission indicator] + tail
|
||||
let preview: string
|
||||
if (this.omittedBytes > 0) {
|
||||
const omissionIndicator = `\n[...${this.omittedBytes} bytes omitted...]\n`
|
||||
preview = this.headBuffer + omissionIndicator + this.tailBuffer
|
||||
} else {
|
||||
// No truncation, just combine head and tail (or head alone if tail is empty)
|
||||
preview = this.headBuffer + this.tailBuffer
|
||||
}
|
||||
|
||||
return {
|
||||
preview,
|
||||
totalBytes: this.totalBytes,
|
||||
artifactPath: this.spilledToDisk ? this.artifactPath : null,
|
||||
truncated: this.spilledToDisk,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current buffer content for UI display.
|
||||
*
|
||||
* Returns the combined head + tail content for real-time UI updates.
|
||||
* Note: Does not include the omission indicator to avoid flickering during streaming.
|
||||
*
|
||||
* @returns The current buffer content as a string
|
||||
*/
|
||||
getBufferForUI(): string {
|
||||
// For UI, return combined head + tail without omission indicator
|
||||
// This provides a smoother streaming experience
|
||||
return this.headBuffer + this.tailBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the artifact file path for this command execution.
|
||||
*
|
||||
* Returns the path where the full output would be/is stored on disk.
|
||||
* The file may not exist if output hasn't exceeded the preview threshold.
|
||||
*
|
||||
* @returns The absolute path to the artifact file
|
||||
*/
|
||||
getArtifactPath(): string {
|
||||
return this.artifactPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the output has been spilled to disk.
|
||||
*
|
||||
* @returns `true` if output exceeded threshold and was written to disk
|
||||
*/
|
||||
hasSpilledToDisk(): boolean {
|
||||
return this.spilledToDisk
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all command output artifact files from a directory.
|
||||
*
|
||||
* Deletes all files matching the pattern `cmd-*.txt` in the specified directory.
|
||||
* This is typically called when a task is cleaned up or reset.
|
||||
*
|
||||
* @param storageDir - The directory containing artifact files to clean
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await OutputInterceptor.cleanup('/path/to/task/command-output');
|
||||
* ```
|
||||
*/
|
||||
static async cleanup(storageDir: string): Promise<void> {
|
||||
try {
|
||||
const files = await fs.promises.readdir(storageDir)
|
||||
for (const file of files) {
|
||||
if (file.startsWith("cmd-")) {
|
||||
await fs.promises.unlink(path.join(storageDir, file)).catch(() => {})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist, nothing to clean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove artifact files that are NOT in the provided set of execution IDs.
|
||||
*
|
||||
* This is used for selective cleanup, preserving artifacts that are still
|
||||
* referenced in the conversation history while removing orphaned files.
|
||||
*
|
||||
* @param storageDir - The directory containing artifact files
|
||||
* @param executionIds - Set of execution IDs to preserve (files NOT in this set are deleted)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Keep only artifacts for executions 123 and 456
|
||||
* const keepIds = new Set(['123', '456']);
|
||||
* await OutputInterceptor.cleanupByIds('/path/to/command-output', keepIds);
|
||||
* ```
|
||||
*/
|
||||
static async cleanupByIds(storageDir: string, executionIds: Set<string>): Promise<void> {
|
||||
try {
|
||||
const files = await fs.promises.readdir(storageDir)
|
||||
for (const file of files) {
|
||||
const match = file.match(/^cmd-(\d+)\.txt$/)
|
||||
if (match && !executionIds.has(match[1])) {
|
||||
await fs.promises.unlink(path.join(storageDir, file)).catch(() => {})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist, nothing to clean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,9 @@ describe("ExecaTerminal", () => {
|
|||
|
||||
const callbacks: RooTerminalCallbacks = {
|
||||
onLine: vi.fn(),
|
||||
onCompleted: (output) => (result = output),
|
||||
onCompleted: (output) => {
|
||||
result = output
|
||||
},
|
||||
onShellExecutionStarted: vi.fn(),
|
||||
onShellExecutionComplete: vi.fn(),
|
||||
}
|
||||
|
|
|
|||
532
src/integrations/terminal/__tests__/OutputInterceptor.test.ts
Normal file
532
src/integrations/terminal/__tests__/OutputInterceptor.test.ts
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
|
||||
import { OutputInterceptor } from "../OutputInterceptor"
|
||||
import { TerminalOutputPreviewSize } from "@roo-code/types"
|
||||
|
||||
// Mock filesystem operations
|
||||
vi.mock("fs", () => ({
|
||||
default: {
|
||||
existsSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
createWriteStream: vi.fn(),
|
||||
promises: {
|
||||
readdir: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
},
|
||||
},
|
||||
existsSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
createWriteStream: vi.fn(),
|
||||
promises: {
|
||||
readdir: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("OutputInterceptor", () => {
|
||||
let mockWriteStream: any
|
||||
let storageDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
storageDir = path.normalize("/tmp/test-storage")
|
||||
|
||||
// Setup mock write stream with callback support for end()
|
||||
mockWriteStream = {
|
||||
write: vi.fn(),
|
||||
end: vi.fn((callback?: () => void) => {
|
||||
// Immediately call the callback to simulate stream flush completing
|
||||
if (callback) callback()
|
||||
}),
|
||||
on: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("Buffering behavior", () => {
|
||||
it("should keep small output in memory without spilling to disk", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "echo test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB
|
||||
})
|
||||
|
||||
const smallOutput = "Hello World\n"
|
||||
interceptor.write(smallOutput)
|
||||
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
expect(fs.createWriteStream).not.toHaveBeenCalled()
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
expect(result.preview).toBe(smallOutput)
|
||||
expect(result.truncated).toBe(false)
|
||||
expect(result.artifactPath).toBe(null)
|
||||
expect(result.totalBytes).toBe(Buffer.byteLength(smallOutput, "utf8"))
|
||||
})
|
||||
|
||||
it("should spill to disk when output exceeds threshold", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "echo test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120 bytes
|
||||
})
|
||||
|
||||
// Write enough data to exceed 5KB threshold
|
||||
const chunk = "x".repeat(2 * 1024) // 2KB chunk
|
||||
interceptor.write(chunk) // 2KB - should stay in memory
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
|
||||
interceptor.write(chunk) // 4KB - should stay in memory
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
|
||||
interceptor.write(chunk) // 6KB - should trigger spill
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, "cmd-12345.txt"))
|
||||
expect(mockWriteStream.write).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should truncate preview after spilling to disk using head/tail split", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "echo test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB
|
||||
})
|
||||
|
||||
// Write data that exceeds threshold
|
||||
const chunk = "x".repeat(6000)
|
||||
interceptor.write(chunk)
|
||||
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt"))
|
||||
// Preview is head (1024) + omission indicator + tail (1024)
|
||||
// The omission indicator adds some extra bytes
|
||||
expect(result.preview).toContain("[...")
|
||||
expect(result.preview).toContain("bytes omitted...]")
|
||||
})
|
||||
|
||||
it("should write subsequent chunks directly to disk after spilling", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "echo test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
})
|
||||
|
||||
// Trigger spill (must exceed 5KB = 5120 bytes)
|
||||
const largeChunk = "x".repeat(6000)
|
||||
interceptor.write(largeChunk)
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
|
||||
// Clear mock to track next write
|
||||
mockWriteStream.write.mockClear()
|
||||
|
||||
// Write another chunk - should go directly to disk
|
||||
const nextChunk = "y".repeat(1000)
|
||||
interceptor.write(nextChunk)
|
||||
|
||||
expect(mockWriteStream.write).toHaveBeenCalledWith(nextChunk)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Threshold settings", () => {
|
||||
it("should handle small (5KB) threshold correctly", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
})
|
||||
|
||||
// Write exactly 5KB
|
||||
interceptor.write("x".repeat(5 * 1024))
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
|
||||
// Write more to exceed 5KB
|
||||
interceptor.write("x")
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle medium (10KB) threshold correctly", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "medium",
|
||||
})
|
||||
|
||||
// Write exactly 10KB
|
||||
interceptor.write("x".repeat(10 * 1024))
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
|
||||
// Write more to exceed 10KB
|
||||
interceptor.write("x")
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle large (20KB) threshold correctly", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "large",
|
||||
})
|
||||
|
||||
// Write exactly 20KB
|
||||
interceptor.write("x".repeat(20 * 1024))
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
|
||||
// Write more to exceed 20KB
|
||||
interceptor.write("x")
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Artifact creation", () => {
|
||||
it("should create directory if it doesn't exist", () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false)
|
||||
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
})
|
||||
|
||||
// Trigger spill (must exceed 5KB = 5120 bytes)
|
||||
interceptor.write("x".repeat(6000))
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(storageDir, { recursive: true })
|
||||
})
|
||||
|
||||
it("should create artifact file with correct naming pattern", () => {
|
||||
const executionId = "1706119234567"
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId,
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
})
|
||||
|
||||
// Trigger spill (must exceed 5KB = 5120 bytes)
|
||||
interceptor.write("x".repeat(6000))
|
||||
|
||||
expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, `cmd-${executionId}.txt`))
|
||||
})
|
||||
|
||||
it("should write head and tail buffers to artifact when spilling", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120 bytes, so head=2560, tail=2560
|
||||
})
|
||||
|
||||
const fullOutput = "x".repeat(10000)
|
||||
interceptor.write(fullOutput)
|
||||
|
||||
// The write stream should receive the head buffer content first
|
||||
// (spillToDisk writes head + tail that existed at spill time)
|
||||
expect(mockWriteStream.write).toHaveBeenCalled()
|
||||
// Verify that we're writing to disk
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
})
|
||||
|
||||
it("should get artifact path from getArtifactPath() method", () => {
|
||||
const executionId = "12345"
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId,
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
})
|
||||
|
||||
const expectedPath = path.join(storageDir, `cmd-${executionId}.txt`)
|
||||
expect(interceptor.getArtifactPath()).toBe(expectedPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe("finalize() method", () => {
|
||||
it("should return preview output for small commands", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "echo hello",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
})
|
||||
|
||||
const output = "Hello World\n"
|
||||
interceptor.write(output)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
|
||||
expect(result.preview).toBe(output)
|
||||
expect(result.totalBytes).toBe(Buffer.byteLength(output, "utf8"))
|
||||
expect(result.artifactPath).toBe(null)
|
||||
expect(result.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it("should return PersistedCommandOutput for large commands with head/tail preview", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
|
||||
})
|
||||
|
||||
const largeOutput = "x".repeat(10000)
|
||||
interceptor.write(largeOutput)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt"))
|
||||
expect(result.totalBytes).toBe(Buffer.byteLength(largeOutput, "utf8"))
|
||||
// Preview should contain head + omission indicator + tail
|
||||
expect(result.preview).toContain("[...")
|
||||
expect(result.preview).toContain("bytes omitted...]")
|
||||
})
|
||||
|
||||
it("should close write stream when finalizing", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
})
|
||||
|
||||
// Trigger spill (must exceed 5KB = 5120 bytes)
|
||||
interceptor.write("x".repeat(6000))
|
||||
await interceptor.finalize()
|
||||
|
||||
expect(mockWriteStream.end).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should include correct metadata (artifactId, size, truncated flag)", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
})
|
||||
|
||||
// Must exceed 5KB = 5120 bytes to trigger truncation
|
||||
const output = "x".repeat(6000)
|
||||
interceptor.write(output)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
|
||||
expect(result).toHaveProperty("preview")
|
||||
expect(result).toHaveProperty("totalBytes", 6000)
|
||||
expect(result).toHaveProperty("artifactPath")
|
||||
expect(result).toHaveProperty("truncated", true)
|
||||
expect(result.artifactPath).toMatch(/cmd-12345\.txt$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Cleanup methods", () => {
|
||||
it("should clean up all artifacts in directory", async () => {
|
||||
const mockFiles = ["cmd-12345.txt", "cmd-67890.txt", "other-file.txt", "cmd-11111.txt"]
|
||||
vi.mocked(fs.promises.readdir).mockResolvedValue(mockFiles as any)
|
||||
vi.mocked(fs.promises.unlink).mockResolvedValue(undefined)
|
||||
|
||||
await OutputInterceptor.cleanup(storageDir)
|
||||
|
||||
expect(fs.promises.readdir).toHaveBeenCalledWith(storageDir)
|
||||
expect(fs.promises.unlink).toHaveBeenCalledTimes(3)
|
||||
expect(fs.promises.unlink).toHaveBeenCalledWith(path.join(storageDir, "cmd-12345.txt"))
|
||||
expect(fs.promises.unlink).toHaveBeenCalledWith(path.join(storageDir, "cmd-67890.txt"))
|
||||
expect(fs.promises.unlink).toHaveBeenCalledWith(path.join(storageDir, "cmd-11111.txt"))
|
||||
expect(fs.promises.unlink).not.toHaveBeenCalledWith(path.join(storageDir, "other-file.txt"))
|
||||
})
|
||||
|
||||
it("should handle cleanup when directory doesn't exist", async () => {
|
||||
vi.mocked(fs.promises.readdir).mockRejectedValue(new Error("ENOENT"))
|
||||
|
||||
// Should not throw
|
||||
await expect(OutputInterceptor.cleanup(storageDir)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it("should clean up specific artifacts by executionIds", async () => {
|
||||
const mockFiles = ["cmd-12345.txt", "cmd-67890.txt", "cmd-11111.txt"]
|
||||
vi.mocked(fs.promises.readdir).mockResolvedValue(mockFiles as any)
|
||||
vi.mocked(fs.promises.unlink).mockResolvedValue(undefined)
|
||||
|
||||
// Keep 12345 and 67890, delete 11111
|
||||
const keepIds = new Set(["12345", "67890"])
|
||||
await OutputInterceptor.cleanupByIds(storageDir, keepIds)
|
||||
|
||||
expect(fs.promises.unlink).toHaveBeenCalledTimes(1)
|
||||
expect(fs.promises.unlink).toHaveBeenCalledWith(path.join(storageDir, "cmd-11111.txt"))
|
||||
expect(fs.promises.unlink).not.toHaveBeenCalledWith(path.join(storageDir, "cmd-12345.txt"))
|
||||
expect(fs.promises.unlink).not.toHaveBeenCalledWith(path.join(storageDir, "cmd-67890.txt"))
|
||||
})
|
||||
|
||||
it("should handle unlink errors gracefully", async () => {
|
||||
const mockFiles = ["cmd-12345.txt", "cmd-67890.txt"]
|
||||
vi.mocked(fs.promises.readdir).mockResolvedValue(mockFiles as any)
|
||||
vi.mocked(fs.promises.unlink).mockRejectedValue(new Error("Permission denied"))
|
||||
|
||||
// Should not throw even if unlink fails
|
||||
await expect(OutputInterceptor.cleanup(storageDir)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getBufferForUI() method", () => {
|
||||
it("should return current buffer for UI updates", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
})
|
||||
|
||||
const output = "Hello World"
|
||||
interceptor.write(output)
|
||||
|
||||
expect(interceptor.getBufferForUI()).toBe(output)
|
||||
})
|
||||
|
||||
it("should return head + tail buffer after spilling to disk", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
|
||||
})
|
||||
|
||||
// Trigger spill
|
||||
const largeOutput = "x".repeat(10000)
|
||||
interceptor.write(largeOutput)
|
||||
|
||||
const buffer = interceptor.getBufferForUI()
|
||||
// Buffer for UI is head + tail (no omission indicator for smooth streaming)
|
||||
expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(5120)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Head/Tail split behavior", () => {
|
||||
it("should preserve first 50% and last 50% of output", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
|
||||
})
|
||||
|
||||
// Create identifiable head and tail content
|
||||
const headContent = "HEAD".repeat(750) // 3000 bytes
|
||||
const middleContent = "M".repeat(6000) // 6000 bytes (will be omitted)
|
||||
const tailContent = "TAIL".repeat(750) // 3000 bytes
|
||||
|
||||
interceptor.write(headContent)
|
||||
interceptor.write(middleContent)
|
||||
interceptor.write(tailContent)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
|
||||
// Should start with HEAD content (first 2560 bytes of head budget)
|
||||
expect(result.preview.startsWith("HEAD")).toBe(true)
|
||||
// Should end with TAIL content (last 2560 bytes)
|
||||
expect(result.preview.endsWith("TAIL")).toBe(true)
|
||||
// Should have omission indicator
|
||||
expect(result.preview).toContain("[...")
|
||||
expect(result.preview).toContain("bytes omitted...]")
|
||||
})
|
||||
|
||||
it("should not add omission indicator when output fits in budget", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB
|
||||
})
|
||||
|
||||
const smallOutput = "Hello World\n"
|
||||
interceptor.write(smallOutput)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
|
||||
// No omission indicator for small output
|
||||
expect(result.preview).toBe(smallOutput)
|
||||
expect(result.preview).not.toContain("[...")
|
||||
})
|
||||
|
||||
it("should handle output that exactly fills head budget", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120, head=2560
|
||||
})
|
||||
|
||||
// Write exactly 2560 bytes (head budget)
|
||||
const exactHeadContent = "x".repeat(2560)
|
||||
interceptor.write(exactHeadContent)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
|
||||
// Should fit entirely in head, no truncation
|
||||
expect(result.preview).toBe(exactHeadContent)
|
||||
expect(result.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it("should split single large chunk across head and tail", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
|
||||
})
|
||||
|
||||
// Write a single chunk larger than preview budget
|
||||
// First 2560 chars go to head, last 2560 chars go to tail
|
||||
const content = "A".repeat(2560) + "B".repeat(4000) + "C".repeat(2560)
|
||||
interceptor.write(content)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
|
||||
// Head should have A's
|
||||
expect(result.preview.startsWith("A")).toBe(true)
|
||||
// Tail should have C's
|
||||
expect(result.preview.endsWith("C")).toBe(true)
|
||||
// Should have omission indicator
|
||||
expect(result.preview).toContain("[...")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -22,7 +22,7 @@ export interface RooTerminal {
|
|||
|
||||
export interface RooTerminalCallbacks {
|
||||
onLine: (line: string, process: RooTerminalProcess) => void
|
||||
onCompleted: (output: string | undefined, process: RooTerminalProcess) => void
|
||||
onCompleted: (output: string | undefined, process: RooTerminalProcess) => void | Promise<void>
|
||||
onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => void
|
||||
onShellExecutionComplete: (details: ExitCodeDetails, process: RooTerminalProcess) => void
|
||||
onNoShellIntegration?: (message: string, process: RooTerminalProcess) => void
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"displayName": "%extension.displayName%",
|
||||
"description": "%extension.description%",
|
||||
"publisher": "RooVeterinaryInc",
|
||||
"version": "3.44.1",
|
||||
"version": "3.45.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
|
|
@ -529,9 +529,10 @@
|
|||
"web-tree-sitter": "^0.25.6",
|
||||
"workerpool": "^9.2.0",
|
||||
"yaml": "^2.8.0",
|
||||
"zod": "3.25.61"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openrouter/ai-sdk-provider": "^2.0.4",
|
||||
"@roo-code/build": "workspace:^",
|
||||
"@roo-code/config-eslint": "workspace:^",
|
||||
"@roo-code/config-typescript": "workspace:^",
|
||||
|
|
@ -556,6 +557,7 @@
|
|||
"@types/vscode": "^1.84.0",
|
||||
"@vscode/test-electron": "^2.5.2",
|
||||
"@vscode/vsce": "3.3.2",
|
||||
"ai": "^6.0.0",
|
||||
"esbuild-wasm": "^0.25.0",
|
||||
"execa": "^9.5.2",
|
||||
"glob": "^11.1.0",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ export const toolParamNames = [
|
|||
"old_string", // search_replace and edit_file parameter
|
||||
"new_string", // search_replace and edit_file parameter
|
||||
"expected_replacements", // edit_file parameter for multiple occurrences
|
||||
"artifact_id", // read_command_output parameter
|
||||
"search", // read_command_output parameter for grep-like search
|
||||
"offset", // read_command_output parameter for pagination
|
||||
"limit", // read_command_output parameter for max bytes to return
|
||||
] as const
|
||||
|
||||
export type ToolParamName = (typeof toolParamNames)[number]
|
||||
|
|
@ -83,6 +87,7 @@ export type ToolParamName = (typeof toolParamNames)[number]
|
|||
export type NativeToolArgs = {
|
||||
access_mcp_resource: { server_name: string; uri: string }
|
||||
read_file: { files: FileEntry[] }
|
||||
read_command_output: { artifact_id: string; search?: string; offset?: number; limit?: number }
|
||||
attempt_completion: { result: string }
|
||||
execute_command: { command: string; cwd?: string }
|
||||
apply_diff: { path: string; diff: string }
|
||||
|
|
@ -242,6 +247,7 @@ export type ToolGroupConfig = {
|
|||
export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
|
||||
execute_command: "run commands",
|
||||
read_file: "read files",
|
||||
read_command_output: "read command output",
|
||||
fetch_instructions: "fetch instructions",
|
||||
write_to_file: "write files",
|
||||
apply_diff: "apply changes",
|
||||
|
|
@ -278,7 +284,7 @@ export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
|
|||
tools: ["browser_action"],
|
||||
},
|
||||
command: {
|
||||
tools: ["execute_command"],
|
||||
tools: ["execute_command", "read_command_output"],
|
||||
},
|
||||
mcp: {
|
||||
tools: ["use_mcp_tool", "access_mcp_resource"],
|
||||
|
|
|
|||
|
|
@ -44,12 +44,7 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {
|
|||
<div className="mb-4">
|
||||
<p className="mb-3">{t("chat:announcement.release.heading")}</p>
|
||||
<ul className="list-disc list-inside text-sm space-y-1.5">
|
||||
<li>
|
||||
<Trans
|
||||
i18nKey="chat:announcement.release.worktrees"
|
||||
components={{ settingsLink: <WorktreesSettingsLink /> }}
|
||||
/>
|
||||
</li>
|
||||
<li>{t("chat:announcement.release.smartCodeFolding")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
@ -127,18 +122,4 @@ const CareersLink = ({ children }: { children?: ReactNode }) => (
|
|||
</VSCodeLink>
|
||||
)
|
||||
|
||||
const WorktreesSettingsLink = ({ children }: { children?: ReactNode }) => (
|
||||
<VSCodeLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.postMessage(
|
||||
{ type: "action", action: "settingsButtonClicked", values: { section: "worktrees" } },
|
||||
"*",
|
||||
)
|
||||
}}>
|
||||
{children}
|
||||
</VSCodeLink>
|
||||
)
|
||||
|
||||
export default memo(Announcement)
|
||||
|
|
|
|||
|
|
@ -1464,6 +1464,51 @@ export const ChatRowContent = ({
|
|||
</>
|
||||
)
|
||||
}
|
||||
case "readCommandOutput": {
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
// Determine if this is a search operation
|
||||
const isSearch = sayTool.searchPattern !== undefined
|
||||
|
||||
let infoText = ""
|
||||
if (isSearch) {
|
||||
// Search mode: show pattern and match count
|
||||
const matchText =
|
||||
sayTool.matchCount !== undefined
|
||||
? sayTool.matchCount === 1
|
||||
? "1 match"
|
||||
: `${sayTool.matchCount} matches`
|
||||
: ""
|
||||
infoText = `search: "${sayTool.searchPattern}"${matchText ? ` • ${matchText}` : ""}`
|
||||
} else if (
|
||||
sayTool.readStart !== undefined &&
|
||||
sayTool.readEnd !== undefined &&
|
||||
sayTool.totalBytes !== undefined
|
||||
) {
|
||||
// Read mode: show byte range
|
||||
infoText = `${formatBytes(sayTool.readStart)} - ${formatBytes(sayTool.readEnd)} of ${formatBytes(sayTool.totalBytes)}`
|
||||
} else if (sayTool.totalBytes !== undefined) {
|
||||
infoText = formatBytes(sayTool.totalBytes)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={headerStyle}>
|
||||
<FileCode2 className="w-4 shrink-0" aria-label="Read command output icon" />
|
||||
<span style={{ fontWeight: "bold" }}>{t("chat:readCommandOutput.title")}</span>
|
||||
{infoText && (
|
||||
<span
|
||||
className="text-xs ml-1"
|
||||
style={{ color: "var(--vscode-descriptionForeground)" }}>
|
||||
({infoText})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,20 @@ const TaskHeader = ({
|
|||
const textRef = useRef<HTMLDivElement>(null)
|
||||
const contextWindow = model?.contextWindow || 1
|
||||
|
||||
// Calculate maxTokens (reserved for output) once for reuse in percentage and tooltip
|
||||
const maxTokens = useMemo(
|
||||
() =>
|
||||
model
|
||||
? getModelMaxOutputTokens({
|
||||
modelId,
|
||||
model,
|
||||
settings: apiConfiguration,
|
||||
})
|
||||
: 0,
|
||||
[model, modelId, apiConfiguration],
|
||||
)
|
||||
const reservedForOutput = maxTokens || 0
|
||||
|
||||
// Detect if this task had any browser session activity so we can show a grey globe when inactive
|
||||
const browserSessionStartIndex = useMemo(() => {
|
||||
const msgs = clineMessages || []
|
||||
|
|
@ -226,14 +240,6 @@ const TaskHeader = ({
|
|||
<div className="flex items-center gap-2">
|
||||
<StandardTooltip
|
||||
content={(() => {
|
||||
const maxTokens = model
|
||||
? getModelMaxOutputTokens({
|
||||
modelId,
|
||||
model,
|
||||
settings: apiConfiguration,
|
||||
})
|
||||
: 0
|
||||
const reservedForOutput = maxTokens || 0
|
||||
const availableSpace = contextWindow - (contextTokens || 0) - reservedForOutput
|
||||
|
||||
return (
|
||||
|
|
@ -276,7 +282,13 @@ const TaskHeader = ({
|
|||
sideOffset={8}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{(() => {
|
||||
const percentage = Math.round(((contextTokens || 0) / contextWindow) * 100)
|
||||
// Calculate percentage of available input space used
|
||||
// Available input space = context window - reserved for output
|
||||
const availableInputSpace = contextWindow - reservedForOutput
|
||||
const percentage =
|
||||
availableInputSpace > 0
|
||||
? Math.round(((contextTokens || 0) / availableInputSpace) * 100)
|
||||
: 0
|
||||
return (
|
||||
<>
|
||||
<CircularProgress percentage={percentage} />
|
||||
|
|
@ -397,15 +409,7 @@ const TaskHeader = ({
|
|||
<ContextWindowProgress
|
||||
contextWindow={contextWindow}
|
||||
contextTokens={contextTokens || 0}
|
||||
maxTokens={
|
||||
model
|
||||
? getModelMaxOutputTokens({
|
||||
modelId,
|
||||
model,
|
||||
settings: apiConfiguration,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
maxTokens={maxTokens || undefined}
|
||||
/>
|
||||
{condenseButton}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -91,6 +91,26 @@ vi.mock("@roo/array", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
// Create a variable to hold the mock model info for useSelectedModel
|
||||
let mockModelInfo: { contextWindow: number; maxTokens: number } | undefined = undefined
|
||||
|
||||
// Mock useSelectedModel hook
|
||||
vi.mock("@/components/ui/hooks/useSelectedModel", () => ({
|
||||
useSelectedModel: () => ({
|
||||
provider: "anthropic",
|
||||
id: "test-model",
|
||||
info: mockModelInfo,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock getModelMaxOutputTokens from @roo/api
|
||||
let mockMaxOutputTokens = 0
|
||||
vi.mock("@roo/api", () => ({
|
||||
getModelMaxOutputTokens: () => mockMaxOutputTokens,
|
||||
}))
|
||||
|
||||
describe("TaskHeader", () => {
|
||||
const defaultProps: TaskHeaderProps = {
|
||||
task: { type: "say", ts: Date.now(), text: "Test task", images: [] },
|
||||
|
|
@ -402,4 +422,52 @@ describe("TaskHeader", () => {
|
|||
expect(backButton?.querySelector("svg.lucide-arrow-left")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Context window percentage calculation", () => {
|
||||
// The percentage should be calculated as:
|
||||
// contextTokens / (contextWindow - reservedForOutput) * 100
|
||||
// This represents the percentage of AVAILABLE input space used,
|
||||
// not the percentage of the total context window.
|
||||
|
||||
beforeEach(() => {
|
||||
// Set up mock model with known contextWindow
|
||||
mockModelInfo = { contextWindow: 1000, maxTokens: 200 }
|
||||
// Set up mock for getModelMaxOutputTokens to return reservedForOutput
|
||||
mockMaxOutputTokens = 200
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Reset mocks
|
||||
mockModelInfo = undefined
|
||||
mockMaxOutputTokens = 0
|
||||
})
|
||||
|
||||
it("should calculate percentage based on available input space, not total context window", () => {
|
||||
// With the formula: contextTokens / (contextWindow - reservedForOutput) * 100
|
||||
// If contextTokens = 200, contextWindow = 1000, reservedForOutput = 200
|
||||
// Then available input space = 1000 - 200 = 800
|
||||
// Percentage = 200 / 800 * 100 = 25%
|
||||
//
|
||||
// Old (incorrect) formula would have been: (200 + 200) / 1000 * 100 = 40%
|
||||
|
||||
renderTaskHeader({ contextTokens: 200 })
|
||||
|
||||
// The percentage should be rendered in the collapsed header state
|
||||
// Verify that 25% is displayed (correct formula) and NOT 40% (old incorrect formula)
|
||||
expect(screen.getByText("25%")).toBeInTheDocument()
|
||||
expect(screen.queryByText("40%")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should handle edge case when available input space is zero", () => {
|
||||
// When contextWindow equals reservedForOutput, available space is 0
|
||||
// The percentage should be 0 to avoid division by zero
|
||||
mockModelInfo = { contextWindow: 200, maxTokens: 200 }
|
||||
mockMaxOutputTokens = 200
|
||||
|
||||
renderTaskHeader({ contextTokens: 100 })
|
||||
|
||||
// Should show 0% when available input space is 0
|
||||
expect(screen.getByText("0%")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -179,8 +179,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
ttsSpeed,
|
||||
soundVolume,
|
||||
telemetrySetting,
|
||||
terminalOutputLineLimit,
|
||||
terminalOutputCharacterLimit,
|
||||
terminalOutputPreviewSize,
|
||||
terminalShellIntegrationTimeout,
|
||||
terminalShellIntegrationDisabled, // Added from upstream
|
||||
terminalCommandDelay,
|
||||
|
|
@ -196,7 +195,6 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
maxReadFileLine,
|
||||
maxImageFileSize,
|
||||
maxTotalImageSize,
|
||||
terminalCompressProgressBar,
|
||||
maxConcurrentFileReads,
|
||||
customSupportPrompts,
|
||||
profileThresholds,
|
||||
|
|
@ -391,8 +389,6 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
remoteBrowserEnabled: remoteBrowserEnabled ?? false,
|
||||
writeDelayMs,
|
||||
screenshotQuality: screenshotQuality ?? 75,
|
||||
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
|
||||
terminalOutputCharacterLimit: terminalOutputCharacterLimit ?? 50_000,
|
||||
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000,
|
||||
terminalShellIntegrationDisabled,
|
||||
terminalCommandDelay,
|
||||
|
|
@ -401,7 +397,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
terminalZshOhMy,
|
||||
terminalZshP10k,
|
||||
terminalZdotdir,
|
||||
terminalCompressProgressBar,
|
||||
terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium",
|
||||
mcpEnabled,
|
||||
maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500),
|
||||
maxWorkspaceFiles: Math.min(Math.max(0, maxWorkspaceFiles ?? 200), 500),
|
||||
|
|
@ -872,8 +868,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
{/* Terminal Section */}
|
||||
{renderTab === "terminal" && (
|
||||
<TerminalSettings
|
||||
terminalOutputLineLimit={terminalOutputLineLimit}
|
||||
terminalOutputCharacterLimit={terminalOutputCharacterLimit}
|
||||
terminalOutputPreviewSize={terminalOutputPreviewSize}
|
||||
terminalShellIntegrationTimeout={terminalShellIntegrationTimeout}
|
||||
terminalShellIntegrationDisabled={terminalShellIntegrationDisabled}
|
||||
terminalCommandDelay={terminalCommandDelay}
|
||||
|
|
@ -882,7 +877,6 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
terminalZshOhMy={terminalZshOhMy}
|
||||
terminalZshP10k={terminalZshP10k}
|
||||
terminalZdotdir={terminalZdotdir}
|
||||
terminalCompressProgressBar={terminalCompressProgressBar}
|
||||
setCachedStateField={setCachedStateField}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import { Trans } from "react-i18next"
|
|||
import { buildDocLink } from "@src/utils/docLinks"
|
||||
import { useEvent, useMount } from "react-use"
|
||||
|
||||
import { type ExtensionMessage } from "@roo-code/types"
|
||||
import { type ExtensionMessage, type TerminalOutputPreviewSize } from "@roo-code/types"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Slider } from "@/components/ui"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Slider } from "@/components/ui"
|
||||
|
||||
import { SetCachedStateField } from "./types"
|
||||
import { SectionHeader } from "./SectionHeader"
|
||||
|
|
@ -17,8 +17,7 @@ import { Section } from "./Section"
|
|||
import { SearchableSetting } from "./SearchableSetting"
|
||||
|
||||
type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
terminalOutputLineLimit?: number
|
||||
terminalOutputCharacterLimit?: number
|
||||
terminalOutputPreviewSize?: TerminalOutputPreviewSize
|
||||
terminalShellIntegrationTimeout?: number
|
||||
terminalShellIntegrationDisabled?: boolean
|
||||
terminalCommandDelay?: number
|
||||
|
|
@ -27,10 +26,8 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
|||
terminalZshOhMy?: boolean
|
||||
terminalZshP10k?: boolean
|
||||
terminalZdotdir?: boolean
|
||||
terminalCompressProgressBar?: boolean
|
||||
setCachedStateField: SetCachedStateField<
|
||||
| "terminalOutputLineLimit"
|
||||
| "terminalOutputCharacterLimit"
|
||||
| "terminalOutputPreviewSize"
|
||||
| "terminalShellIntegrationTimeout"
|
||||
| "terminalShellIntegrationDisabled"
|
||||
| "terminalCommandDelay"
|
||||
|
|
@ -39,13 +36,11 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
|||
| "terminalZshOhMy"
|
||||
| "terminalZshP10k"
|
||||
| "terminalZdotdir"
|
||||
| "terminalCompressProgressBar"
|
||||
>
|
||||
}
|
||||
|
||||
export const TerminalSettings = ({
|
||||
terminalOutputLineLimit,
|
||||
terminalOutputCharacterLimit,
|
||||
terminalOutputPreviewSize,
|
||||
terminalShellIntegrationTimeout,
|
||||
terminalShellIntegrationDisabled,
|
||||
terminalCommandDelay,
|
||||
|
|
@ -54,7 +49,6 @@ export const TerminalSettings = ({
|
|||
terminalZshOhMy,
|
||||
terminalZshP10k,
|
||||
terminalZdotdir,
|
||||
terminalCompressProgressBar,
|
||||
setCachedStateField,
|
||||
className,
|
||||
...props
|
||||
|
|
@ -100,92 +94,34 @@ export const TerminalSettings = ({
|
|||
</div>
|
||||
<div className="flex flex-col gap-3 pl-3 border-l-2 border-vscode-button-background">
|
||||
<SearchableSetting
|
||||
settingId="terminal-output-line-limit"
|
||||
settingId="terminal-output-preview-size"
|
||||
section="terminal"
|
||||
label={t("settings:terminal.outputLineLimit.label")}>
|
||||
label={t("settings:terminal.outputPreviewSize.label")}>
|
||||
<label className="block font-medium mb-1">
|
||||
{t("settings:terminal.outputLineLimit.label")}
|
||||
{t("settings:terminal.outputPreviewSize.label")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
min={100}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={[terminalOutputLineLimit ?? 500]}
|
||||
onValueChange={([value]) => setCachedStateField("terminalOutputLineLimit", value)}
|
||||
data-testid="terminal-output-limit-slider"
|
||||
/>
|
||||
<span className="w-10">{terminalOutputLineLimit ?? 500}</span>
|
||||
</div>
|
||||
<Select
|
||||
value={terminalOutputPreviewSize || "medium"}
|
||||
onValueChange={(value) =>
|
||||
setCachedStateField("terminalOutputPreviewSize", value as TerminalOutputPreviewSize)
|
||||
}>
|
||||
<SelectTrigger className="w-full" data-testid="terminal-output-preview-size-dropdown">
|
||||
<SelectValue placeholder={t("settings:common.select")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">
|
||||
{t("settings:terminal.outputPreviewSize.options.small")}
|
||||
</SelectItem>
|
||||
<SelectItem value="medium">
|
||||
{t("settings:terminal.outputPreviewSize.options.medium")}
|
||||
</SelectItem>
|
||||
<SelectItem value="large">
|
||||
{t("settings:terminal.outputPreviewSize.options.large")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-1">
|
||||
<Trans i18nKey="settings:terminal.outputLineLimit.description">
|
||||
<VSCodeLink
|
||||
href={buildDocLink(
|
||||
"features/shell-integration#terminal-output-limit",
|
||||
"settings_terminal_output_limit",
|
||||
)}
|
||||
style={{ display: "inline" }}>
|
||||
{" "}
|
||||
</VSCodeLink>
|
||||
</Trans>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
<SearchableSetting
|
||||
settingId="terminal-output-character-limit"
|
||||
section="terminal"
|
||||
label={t("settings:terminal.outputCharacterLimit.label")}>
|
||||
<label className="block font-medium mb-1">
|
||||
{t("settings:terminal.outputCharacterLimit.label")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={[terminalOutputCharacterLimit ?? 50000]}
|
||||
onValueChange={([value]) =>
|
||||
setCachedStateField("terminalOutputCharacterLimit", value)
|
||||
}
|
||||
data-testid="terminal-output-character-limit-slider"
|
||||
/>
|
||||
<span className="w-16">{terminalOutputCharacterLimit ?? 50000}</span>
|
||||
</div>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-1">
|
||||
<Trans i18nKey="settings:terminal.outputCharacterLimit.description">
|
||||
<VSCodeLink
|
||||
href={buildDocLink(
|
||||
"features/shell-integration#terminal-output-limit",
|
||||
"settings_terminal_output_character_limit",
|
||||
)}
|
||||
style={{ display: "inline" }}>
|
||||
{" "}
|
||||
</VSCodeLink>
|
||||
</Trans>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
<SearchableSetting
|
||||
settingId="terminal-compress-progress-bar"
|
||||
section="terminal"
|
||||
label={t("settings:terminal.compressProgressBar.label")}>
|
||||
<VSCodeCheckbox
|
||||
checked={terminalCompressProgressBar ?? true}
|
||||
onChange={(e: any) =>
|
||||
setCachedStateField("terminalCompressProgressBar", e.target.checked)
|
||||
}
|
||||
data-testid="terminal-compress-progress-bar-checkbox">
|
||||
<span className="font-medium">{t("settings:terminal.compressProgressBar.label")}</span>
|
||||
</VSCodeCheckbox>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-1">
|
||||
<Trans i18nKey="settings:terminal.compressProgressBar.description">
|
||||
<VSCodeLink
|
||||
href={buildDocLink(
|
||||
"features/shell-integration#compress-progress-bar-output",
|
||||
"settings_terminal_compress_progress_bar",
|
||||
)}
|
||||
style={{ display: "inline" }}>
|
||||
{" "}
|
||||
</VSCodeLink>
|
||||
</Trans>
|
||||
{t("settings:terminal.outputPreviewSize.description")}
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -193,7 +193,6 @@ describe("SettingsView - Change Detection Fix", () => {
|
|||
maxReadFileLine: -1,
|
||||
maxImageFileSize: 5,
|
||||
maxTotalImageSize: 20,
|
||||
terminalCompressProgressBar: false,
|
||||
maxConcurrentFileReads: 5,
|
||||
customCondensingPrompt: "",
|
||||
customSupportPrompts: {},
|
||||
|
|
|
|||
|
|
@ -198,7 +198,6 @@ describe("SettingsView - Unsaved Changes Detection", () => {
|
|||
maxReadFileLine: -1,
|
||||
maxImageFileSize: 5,
|
||||
maxTotalImageSize: 20,
|
||||
terminalCompressProgressBar: false,
|
||||
maxConcurrentFileReads: 5,
|
||||
customCondensingPrompt: "",
|
||||
customSupportPrompts: {},
|
||||
|
|
|
|||
|
|
@ -96,10 +96,8 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
setWriteDelayMs: (value: number) => void
|
||||
screenshotQuality?: number
|
||||
setScreenshotQuality: (value: number) => void
|
||||
terminalOutputLineLimit?: number
|
||||
setTerminalOutputLineLimit: (value: number) => void
|
||||
terminalOutputCharacterLimit?: number
|
||||
setTerminalOutputCharacterLimit: (value: number) => void
|
||||
terminalOutputPreviewSize?: "small" | "medium" | "large"
|
||||
setTerminalOutputPreviewSize: (value: "small" | "medium" | "large") => void
|
||||
mcpEnabled: boolean
|
||||
setMcpEnabled: (value: boolean) => void
|
||||
enableMcpServerCreation: boolean
|
||||
|
|
@ -140,8 +138,6 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
pinnedApiConfigs?: Record<string, boolean>
|
||||
setPinnedApiConfigs: (value: Record<string, boolean>) => void
|
||||
togglePinnedApiConfig: (configName: string) => void
|
||||
terminalCompressProgressBar?: boolean
|
||||
setTerminalCompressProgressBar: (value: boolean) => void
|
||||
setHistoryPreviewCollapsed: (value: boolean) => void
|
||||
setReasoningBlockCollapsed: (value: boolean) => void
|
||||
enterBehavior?: "send" | "newline"
|
||||
|
|
@ -213,8 +209,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
writeDelayMs: 1000,
|
||||
browserViewportSize: "900x600",
|
||||
screenshotQuality: 75,
|
||||
terminalOutputLineLimit: 500,
|
||||
terminalOutputCharacterLimit: 50000,
|
||||
terminalShellIntegrationTimeout: 4000,
|
||||
mcpEnabled: true,
|
||||
enableMcpServerCreation: false,
|
||||
|
|
@ -247,7 +241,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
maxConcurrentFileReads: 5, // Default concurrent file reads
|
||||
terminalZshP10k: false, // Default Powerlevel10k integration setting
|
||||
terminalZdotdir: false, // Default ZDOTDIR handling setting
|
||||
terminalCompressProgressBar: true, // Default to compress progress bar output
|
||||
historyPreviewCollapsed: false, // Initialize the new state (default to expanded)
|
||||
reasoningBlockCollapsed: true, // Default to collapsed
|
||||
enterBehavior: "send", // Default: Enter sends, Shift+Enter creates newline
|
||||
|
|
@ -544,10 +537,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
setState((prevState) => ({ ...prevState, browserViewportSize: value })),
|
||||
setWriteDelayMs: (value) => setState((prevState) => ({ ...prevState, writeDelayMs: value })),
|
||||
setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })),
|
||||
setTerminalOutputLineLimit: (value) =>
|
||||
setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })),
|
||||
setTerminalOutputCharacterLimit: (value) =>
|
||||
setState((prevState) => ({ ...prevState, terminalOutputCharacterLimit: value })),
|
||||
setTerminalOutputPreviewSize: (value) =>
|
||||
setState((prevState) => ({ ...prevState, terminalOutputPreviewSize: value })),
|
||||
setTerminalShellIntegrationTimeout: (value) =>
|
||||
setState((prevState) => ({ ...prevState, terminalShellIntegrationTimeout: value })),
|
||||
setTerminalShellIntegrationDisabled: (value) =>
|
||||
|
|
@ -581,8 +572,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
setMaxImageFileSize: (value) => setState((prevState) => ({ ...prevState, maxImageFileSize: value })),
|
||||
setMaxTotalImageSize: (value) => setState((prevState) => ({ ...prevState, maxTotalImageSize: value })),
|
||||
setPinnedApiConfigs: (value) => setState((prevState) => ({ ...prevState, pinnedApiConfigs: value })),
|
||||
setTerminalCompressProgressBar: (value) =>
|
||||
setState((prevState) => ({ ...prevState, terminalCompressProgressBar: value })),
|
||||
togglePinnedApiConfig: (configId) =>
|
||||
setState((prevState) => {
|
||||
const currentPinned = prevState.pinnedApiConfigs || {}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/ca/chat.json
generated
5
webview-ui/src/i18n/locales/ca/chat.json
generated
|
|
@ -340,7 +340,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Què hi ha de nou:",
|
||||
"worktrees": "Worktrees: Treballa en múltiples branques simultàniament amb Git worktrees. Cada worktree obté la seva pròpia finestra VS Code amb Roo Code, habilitant desenvolupament paral·lel sense canvi de branches. <settingsLink>Prova-ho</settingsLink>"
|
||||
"smartCodeFolding": "Plegament intel·ligent de codi: La condensació de context ara preserva un mapa lleuger dels teus fitxers—signatures de funcions, declaracions de classe i definicions de tipus. Això proporciona millor continuïtat després de condensar i edicions més intel·ligents quan es fa referència a feina anterior."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Novetats al núvol:",
|
||||
|
|
@ -504,5 +504,8 @@
|
|||
"serversPart_other": "{{count}} servidors MCP",
|
||||
"messageTemplate": "Tens {{tools}} habilitades via {{servers}}. Un nombre tant alt pot confondre el model i portar a errors. Intenta mantenir-lo per sota de {{threshold}}.",
|
||||
"openMcpSettings": "Obrir configuració de MCP"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/ca/settings.json
generated
13
webview-ui/src/i18n/locales/ca/settings.json
generated
|
|
@ -724,6 +724,15 @@
|
|||
"label": "Límit de caràcters del terminal",
|
||||
"description": "Anul·la el límit de línies per evitar problemes de memòria imposant un límit dur a la mida de sortida. Si se supera, manté l'inici i el final i mostra un marcador a Roo on s'ha omès el contingut. <0>Aprèn-ne més</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Mida de la previsualització de la sortida d'ordres",
|
||||
"description": "Controla quanta sortida d'ordres veu Roo directament. La sortida completa sempre es desa i és accessible quan calgui.",
|
||||
"options": {
|
||||
"small": "Petita (5KB)",
|
||||
"medium": "Mitjana (10KB)",
|
||||
"large": "Gran (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Temps d'espera d'integració del shell del terminal",
|
||||
"description": "Quant de temps esperar la integració del shell de VS Code abans d'executar comandes. Augmenta si el teu shell s'inicia lentament o veus errors 'Integració del Shell No Disponible'. <0>Aprèn-ne més</0>"
|
||||
|
|
@ -736,10 +745,6 @@
|
|||
"label": "Retard de comanda del terminal",
|
||||
"description": "Afegeix una pausa breu després de cada comanda perquè el terminal de VS Code pugui buidar tota la sortida (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Usa només si veus que falta sortida final; altrament deixa a 0. <0>Aprèn-ne més</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Comprimeix sortida de barra de progrés",
|
||||
"description": "Col·lapsa barres de progrés/spinners perquè només es mantingui l'estat final (estalvia tokens). <0>Aprèn-ne més</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "Activa solució de comptador de PowerShell",
|
||||
"description": "Activa quan falta o es duplica la sortida de PowerShell; afegeix un petit comptador a cada comanda per estabilitzar la sortida. Mantén desactivat si la sortida ja es veu correcta. <0>Aprèn-ne més</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/de/chat.json
generated
5
webview-ui/src/i18n/locales/de/chat.json
generated
|
|
@ -340,7 +340,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Was ist neu:",
|
||||
"worktrees": "Worktrees: Arbeite gleichzeitig an mehreren Branches mit Git Worktrees. Jedes Worktree bekommt sein eigenes VS Code Fenster mit Roo Code, was parallele Entwicklung ohne Branches-Wechsel ermöglicht. <settingsLink>Probiere es aus</settingsLink>"
|
||||
"smartCodeFolding": "Intelligentes Code-Folding: Kontextkomprimierung bewahrt jetzt eine leichte Karte deiner Dateien—Funktionssignaturen, Klassendeklarationen und Typdefinitionen. Dies ermöglicht bessere Kontinuität nach der Komprimierung und intelligentere Bearbeitungen beim Verweisen auf vorherige Arbeiten."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Neu in der Cloud:",
|
||||
|
|
@ -504,5 +504,8 @@
|
|||
"serversPart_other": "{{count}} MCP-Server",
|
||||
"messageTemplate": "Du hast {{tools}} über {{servers}} aktiviert. Eine so hohe Anzahl kann das Modell verwirren und zu Fehlern führen. Versuche, es unter {{threshold}} zu halten.",
|
||||
"openMcpSettings": "MCP-Einstellungen öffnen"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo las Befehlsausgabe"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/de/settings.json
generated
13
webview-ui/src/i18n/locales/de/settings.json
generated
|
|
@ -724,6 +724,15 @@
|
|||
"label": "Terminal-Zeichenlimit",
|
||||
"description": "Überschreibt das Zeilenlimit, um Speicherprobleme durch eine harte Obergrenze für die Ausgabegröße zu vermeiden. Bei Überschreitung behält es Anfang und Ende und zeigt Roo einen Platzhalter, wo Inhalt übersprungen wird. <0>Mehr erfahren</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Befehlsausgabe-Vorschaugröße",
|
||||
"description": "Steuert, wie viel Befehlsausgabe Roo direkt sieht. Die vollständige Ausgabe wird immer gespeichert und ist bei Bedarf zugänglich.",
|
||||
"options": {
|
||||
"small": "Klein (5KB)",
|
||||
"medium": "Mittel (10KB)",
|
||||
"large": "Groß (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Terminal-Shell-Integrations-Timeout",
|
||||
"description": "Wie lange auf VS Code Shell-Integration gewartet wird, bevor Befehle ausgeführt werden. Erhöhe den Wert, wenn deine Shell langsam startet oder du 'Shell-Integration nicht verfügbar'-Fehler siehst. <0>Mehr erfahren</0>"
|
||||
|
|
@ -736,10 +745,6 @@
|
|||
"label": "Terminal-Befehlsverzögerung",
|
||||
"description": "Fügt nach jedem Befehl eine kurze Pause hinzu, damit das VS Code-Terminal alle Ausgaben leeren kann (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Verwende dies nur, wenn du fehlende Tail-Ausgabe siehst; sonst lass es bei 0. <0>Mehr erfahren</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Fortschrittsbalken-Ausgabe komprimieren",
|
||||
"description": "Klappt Fortschrittsbalken/Spinner zusammen, sodass nur der Endzustand erhalten bleibt (spart Token). <0>Mehr erfahren</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "PowerShell-Zähler-Workaround aktivieren",
|
||||
"description": "Schalte dies ein, wenn PowerShell-Ausgabe fehlt oder dupliziert wird; es fügt jedem Befehl einen kleinen Zähler hinzu, um die Ausgabe zu stabilisieren. Lass es ausgeschaltet, wenn die Ausgabe bereits korrekt aussieht. <0>Mehr erfahren</0>"
|
||||
|
|
|
|||
|
|
@ -346,7 +346,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "What's New:",
|
||||
"worktrees": "Worktrees: Work on multiple branches simultaneously with Git worktrees. Each worktree gets its own VS Code window with Roo Code, enabling parallel development without branch switching. <settingsLink>Try it out</settingsLink>"
|
||||
"smartCodeFolding": "Smart Code Folding: Context condensation now preserves a lightweight map of your files: function signatures, class declarations, and type definitions. This provides better continuity after condensing and smarter edits when referencing previous work."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "New in the Cloud:",
|
||||
|
|
@ -495,5 +495,8 @@
|
|||
"serversPart_other": "{{count}} MCP servers",
|
||||
"messageTemplate": "You have {{tools}} enabled via {{servers}}. Such a high number can confuse the model and lead to errors. Try to keep it below {{threshold}}.",
|
||||
"openMcpSettings": "Open MCP Settings"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -733,6 +733,15 @@
|
|||
"label": "Terminal character limit",
|
||||
"description": "Overrides the line limit to prevent memory issues by enforcing a hard cap on output size. If exceeded, keeps the beginning and end and shows a placeholder to Roo where content is skipped. <0>Learn more</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Command output preview size",
|
||||
"description": "Controls how much command output Roo sees directly. Full output is always saved and accessible when needed.",
|
||||
"options": {
|
||||
"small": "Small (5KB)",
|
||||
"medium": "Medium (10KB)",
|
||||
"large": "Large (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Terminal shell integration timeout",
|
||||
"description": "How long to wait for VS Code shell integration before running commands. Raise if your shell starts slowly or you see 'Shell Integration Unavailable' errors. <0>Learn more</0>"
|
||||
|
|
@ -745,10 +754,6 @@
|
|||
"label": "Terminal command delay",
|
||||
"description": "Adds a short pause after each command so the VS Code terminal can flush all output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Use only if you see missing tail output; otherwise leave at 0. <0>Learn more</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Compress progress bar output",
|
||||
"description": "Collapses progress bars/spinners so only the final state is kept (saves tokens). <0>Learn more</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "Enable PowerShell counter workaround",
|
||||
"description": "Turn this on when PowerShell output is missing or duplicated; it appends a tiny counter to each command to stabilize output. Keep this off if output already looks correct. <0>Learn more</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/es/chat.json
generated
5
webview-ui/src/i18n/locales/es/chat.json
generated
|
|
@ -340,7 +340,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Qué hay de nuevo:",
|
||||
"worktrees": "Worktrees: Trabaja en múltiples ramas simultáneamente con Git worktrees. Cada worktree obtiene su propia ventana VS Code con Roo Code, permitiendo desarrollo paralelo sin cambiar de rama. <settingsLink>Pruébalo</settingsLink>"
|
||||
"smartCodeFolding": "Plegado de código inteligente: La condensación de contexto ahora preserva un mapa ligero de tus archivos—firmas de función, declaraciones de clase y definiciones de tipo. Esto proporciona mejor continuidad después de condensar y ediciones más inteligentes al referenciar trabajo anterior."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Novedades en la Nube:",
|
||||
|
|
@ -504,5 +504,8 @@
|
|||
"serversPart_other": "{{count}} servidores MCP",
|
||||
"messageTemplate": "Tienes {{tools}} habilitadas a través de {{servers}}. Un número tan alto puede confundir al modelo y llevar a errores. Intenta mantenerlo por debajo de {{threshold}}.",
|
||||
"openMcpSettings": "Abrir configuración de MCP"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/es/settings.json
generated
13
webview-ui/src/i18n/locales/es/settings.json
generated
|
|
@ -724,6 +724,15 @@
|
|||
"label": "Límite de caracteres del terminal",
|
||||
"description": "Anula el límite de líneas para evitar problemas de memoria imponiendo un límite estricto al tamaño de salida. Si se excede, mantiene el inicio y el final y muestra un marcador a Roo donde se omite el contenido. <0>Más información</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Tamaño de vista previa de salida de comandos",
|
||||
"description": "Controla cuánta salida de comandos ve Roo directamente. La salida completa siempre se guarda y es accesible cuando sea necesario.",
|
||||
"options": {
|
||||
"small": "Pequeño (5KB)",
|
||||
"medium": "Mediano (10KB)",
|
||||
"large": "Grande (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Tiempo de espera de integración del shell del terminal",
|
||||
"description": "Cuánto tiempo esperar la integración del shell de VS Code antes de ejecutar comandos. Aumenta si tu shell inicia lentamente o ves errores 'Integración del Shell No Disponible'. <0>Más información</0>"
|
||||
|
|
@ -736,10 +745,6 @@
|
|||
"label": "Retraso de comando del terminal",
|
||||
"description": "Añade una pausa breve después de cada comando para que el terminal de VS Code pueda vaciar toda la salida (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Usa solo si ves salida final faltante; si no, deja en 0. <0>Más información</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Comprimir salida de barra de progreso",
|
||||
"description": "Colapsa barras de progreso/spinners para que solo se mantenga el estado final (ahorra tokens). <0>Más información</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "Activar solución del contador de PowerShell",
|
||||
"description": "Activa cuando falta o se duplica la salida de PowerShell; añade un pequeño contador a cada comando para estabilizar la salida. Mantén desactivado si la salida ya se ve correcta. <0>Más información</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/fr/chat.json
generated
5
webview-ui/src/i18n/locales/fr/chat.json
generated
|
|
@ -340,7 +340,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Quoi de neuf :",
|
||||
"worktrees": "Worktrees : Travaillez sur plusieurs branches simultanément avec Git worktrees. Chaque worktree obtient sa propre fenêtre VS Code avec Roo Code, permettant un développement parallèle sans changement de branche. <settingsLink>Essaie</settingsLink>"
|
||||
"smartCodeFolding": "Pliage de code intelligent : La condensation du contexte préserve maintenant une carte légère de vos fichiers—signatures de fonctions, déclarations de classes et définitions de types. Cela offre une meilleure continuité après la condensation et des éditions plus intelligentes lors du référencement de travail antérieur."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Nouveautés dans le Cloud :",
|
||||
|
|
@ -504,5 +504,8 @@
|
|||
"serversPart_other": "{{count}} serveurs MCP",
|
||||
"messageTemplate": "Tu as {{tools}} activés via {{servers}}. Un nombre aussi élevé peut confondre le modèle et entraîner des erreurs. Essaie de le maintenir en dessous de {{threshold}}.",
|
||||
"openMcpSettings": "Ouvrir les paramètres MCP"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/fr/settings.json
generated
13
webview-ui/src/i18n/locales/fr/settings.json
generated
|
|
@ -724,6 +724,15 @@
|
|||
"label": "Limite de caractères du terminal",
|
||||
"description": "Remplace la limite de lignes pour éviter les problèmes de mémoire en imposant un plafond strict sur la taille de sortie. Si dépassé, conserve le début et la fin et affiche un espace réservé à Roo là où le contenu est ignoré. <0>En savoir plus</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Taille de l'aperçu de sortie des commandes",
|
||||
"description": "Contrôle la quantité de sortie de commande que Roo voit directement. La sortie complète est toujours sauvegardée et accessible en cas de besoin.",
|
||||
"options": {
|
||||
"small": "Petite (5KB)",
|
||||
"medium": "Moyenne (10KB)",
|
||||
"large": "Grande (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Délai d'attente d'intégration du shell du terminal",
|
||||
"description": "Temps d'attente de l'intégration du shell de VS Code avant d'exécuter des commandes. Augmentez si votre shell démarre lentement ou si vous voyez des erreurs 'Intégration du Shell Indisponible'. <0>En savoir plus</0>"
|
||||
|
|
@ -736,10 +745,6 @@
|
|||
"label": "Délai de commande du terminal",
|
||||
"description": "Ajoute une courte pause après chaque commande pour que le terminal VS Code puisse vider toute la sortie (bash/zsh : PROMPT_COMMAND sleep ; PowerShell : start-sleep). Utilisez uniquement si vous voyez une sortie de fin manquante ; sinon laissez à 0. <0>En savoir plus</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Compresser la sortie de barre de progression",
|
||||
"description": "Réduit les barres de progression/spinners pour ne conserver que l'état final (économise des jetons). <0>En savoir plus</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "Activer la solution de contournement du compteur PowerShell",
|
||||
"description": "Activez lorsque la sortie PowerShell est manquante ou dupliquée ; ajoute un petit compteur à chaque commande pour stabiliser la sortie. Laissez désactivé si la sortie semble déjà correcte. <0>En savoir plus</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/hi/chat.json
generated
5
webview-ui/src/i18n/locales/hi/chat.json
generated
|
|
@ -340,7 +340,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "नया क्या है:",
|
||||
"worktrees": "Worktrees: Git worktrees के साथ एक साथ कई शाखाओं पर काम करें। प्रत्येक worktree को Roo Code के साथ अपनी VS Code विंडो मिलती है, शाखा स्विच किए बिना समानांतर विकास को सक्षम करता है। <settingsLink>इसे आज़माएं</settingsLink>"
|
||||
"smartCodeFolding": "स्मार्ट कोड फोल्डिंग: संदर्भ संघनन अब आपकी फ़ाइलों का एक हल्का मानचित्र संरक्षित करता है—फ़ंक्शन सिग्नेचर, क्लास घोषणाएँ, और टाइप परिभाषाएँ। यह संघनन के बाद बेहतर निरंतरता और पिछले काम को संदर्भित करते समय स्मार्ट संपादन प्रदान करता है।"
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "क्लाउड में नया:",
|
||||
|
|
@ -504,5 +504,8 @@
|
|||
"serversPart_other": "{{count}} MCP सर्वर",
|
||||
"messageTemplate": "आपके पास {{servers}} के माध्यम से {{tools}} सक्षम हैं। इतनी अधिक संख्या मॉडल को भ्रमित कर सकती है और त्रुटियों का कारण बन सकती है। इसे {{threshold}} से नीचे रखने का प्रयास करें।",
|
||||
"openMcpSettings": "MCP सेटिंग्स खोलें"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/hi/settings.json
generated
13
webview-ui/src/i18n/locales/hi/settings.json
generated
|
|
@ -725,6 +725,15 @@
|
|||
"label": "टर्मिनल वर्ण सीमा",
|
||||
"description": "मेमोरी समस्याओं को रोकने के लिए आउटपुट आकार पर कठोर सीमा लगाकर लाइन सीमा को ओवरराइड करता है। यदि पार हो जाती है, तो शुरुआत और अंत रखता है और Roo को प्लेसहोल्डर दिखाता है जहां सामग्री छोड़ी गई है। <0>अधिक जानें</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "कमांड आउटपुट पूर्वावलोकन आकार",
|
||||
"description": "नियंत्रित करता है कि Roo कितना कमांड आउटपुट सीधे देखता है। पूर्ण आउटपुट हमेशा सहेजा जाता है और आवश्यकता पड़ने पर सुलभ होता है।",
|
||||
"options": {
|
||||
"small": "छोटा (5KB)",
|
||||
"medium": "मध्यम (10KB)",
|
||||
"large": "बड़ा (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "टर्मिनल शेल एकीकरण टाइमआउट",
|
||||
"description": "कमांड चलाने से पहले VS Code शेल एकीकरण की प्रतीक्षा करने का समय। यदि आपका शेल धीरे शुरू होता है या आप 'Shell Integration Unavailable' त्रुटियां देखते हैं तो बढ़ाएं। <0>अधिक जानें</0>"
|
||||
|
|
@ -737,10 +746,6 @@
|
|||
"label": "टर्मिनल कमांड विलंब",
|
||||
"description": "प्रत्येक कमांड के बाद छोटा विराम जोड़ता है ताकि VS Code टर्मिनल सभी आउटपुट फ्लश कर सके (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)। केवल तभी उपयोग करें जब टेल आउटपुट गायब हो; अन्यथा 0 पर छोड़ दें। <0>अधिक जानें</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "प्रगति बार आउटपुट संपीड़ित करें",
|
||||
"description": "प्रगति बार/स्पिनर को संक्षिप्त करता है ताकि केवल अंतिम स्थिति रखी जाए (token बचाता है)। <0>अधिक जानें</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "PowerShell काउंटर समाधान सक्षम करें",
|
||||
"description": "जब PowerShell आउटपुट गायब हो या डुप्लिकेट हो तो इसे चालू करें; यह आउटपुट को स्थिर करने के लिए प्रत्येक कमांड में एक छोटा काउंटर जोड़ता है। यदि आउटपुट पहले से सही दिखता है तो इसे बंद रखें। <0>अधिक जानें</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/id/chat.json
generated
5
webview-ui/src/i18n/locales/id/chat.json
generated
|
|
@ -353,7 +353,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Yang Baru:",
|
||||
"worktrees": "Worktrees: Bekerja di beberapa branch secara bersamaan dengan Git worktrees. Setiap worktree mendapat jendela VS Code sendiri dengan Roo Code, memungkinkan pengembangan paralel tanpa pengalihan branch. <settingsLink>Coba</settingsLink>"
|
||||
"smartCodeFolding": "Smart Code Folding: Kondensasi konteks sekarang mempertahankan peta ringan dari file Anda—tanda tangan fungsi, deklarasi kelas, dan definisi tipe. Ini memberikan kontinuitas yang lebih baik setelah kondensasi dan pengeditan yang lebih cerdas saat merujuk pekerjaan sebelumnya."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Baru di Cloud:",
|
||||
|
|
@ -510,5 +510,8 @@
|
|||
"serversPart_other": "{{count}} server MCP",
|
||||
"messageTemplate": "Anda memiliki {{tools}} diaktifkan melalui {{servers}}. Jumlah yang begitu besar dapat membingungkan model dan menyebabkan kesalahan. Cobalah untuk menjaganya di bawah {{threshold}}.",
|
||||
"openMcpSettings": "Buka Pengaturan MCP"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/id/settings.json
generated
13
webview-ui/src/i18n/locales/id/settings.json
generated
|
|
@ -729,6 +729,15 @@
|
|||
"label": "Batas karakter terminal",
|
||||
"description": "Override batas baris untuk mencegah masalah memori dengan memberlakukan cap keras pada ukuran output. Jika terlampaui, simpan awal dan akhir lalu tampilkan placeholder ke Roo di mana konten dilewati. <0>Pelajari lebih lanjut</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Ukuran pratinjau keluaran perintah",
|
||||
"description": "Mengontrol seberapa banyak keluaran perintah yang dilihat Roo secara langsung. Keluaran lengkap selalu disimpan dan dapat diakses saat diperlukan.",
|
||||
"options": {
|
||||
"small": "Kecil (5KB)",
|
||||
"medium": "Sedang (10KB)",
|
||||
"large": "Besar (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Timeout integrasi shell terminal",
|
||||
"description": "Waktu tunggu integrasi shell VS Code sebelum menjalankan perintah. Naikkan jika shell lambat start atau muncul error 'Shell Integration Unavailable'. <0>Pelajari lebih lanjut</0>"
|
||||
|
|
@ -741,10 +750,6 @@
|
|||
"label": "Delay perintah terminal",
|
||||
"description": "Tambahkan jeda singkat setelah setiap perintah agar VS Code terminal bisa flush semua output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Gunakan hanya jika output ekor hilang; jika tidak biarkan di 0. <0>Pelajari lebih lanjut</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Kompres keluaran bilah kemajuan",
|
||||
"description": "Menciutkan bilah kemajuan/spinner sehingga hanya status akhir yang disimpan (menghemat token). <0>Pelajari lebih lanjut</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "Aktifkan solusi penghitung PowerShell",
|
||||
"description": "Aktifkan saat keluaran PowerShell hilang atau digandakan; menambahkan penghitung kecil ke setiap perintah untuk menstabilkan keluaran. Biarkan nonaktif jika keluaran sudah terlihat benar. <0>Pelajari lebih lanjut</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/it/chat.json
generated
5
webview-ui/src/i18n/locales/it/chat.json
generated
|
|
@ -340,7 +340,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Novità:",
|
||||
"worktrees": "Worktrees: Lavora su più branch contemporaneamente con Git worktrees. Ogni worktree ottiene la sua finestra VS Code con Roo Code, abilitando lo sviluppo parallelo senza cambio di branch. <settingsLink>Provalo</settingsLink>"
|
||||
"smartCodeFolding": "Smart Code Folding: La condensazione del contesto mantiene ora una mappa leggera dei tuoi file—firme di funzione, dichiarazioni di classe e definizioni di tipo. Questo fornisce una migliore continuità dopo la condensazione e modifiche più intelligenti quando si fa riferimento al lavoro precedente."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Novità nel Cloud:",
|
||||
|
|
@ -504,5 +504,8 @@
|
|||
"serversPart_other": "{{count}} server MCP",
|
||||
"messageTemplate": "Hai {{tools}} abilitate via {{servers}}. Un numero così alto può confondere il modello e portare a errori. Prova a mantenerlo sotto {{threshold}}.",
|
||||
"openMcpSettings": "Apri impostazioni MCP"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/it/settings.json
generated
13
webview-ui/src/i18n/locales/it/settings.json
generated
|
|
@ -725,6 +725,15 @@
|
|||
"label": "Limite caratteri terminale",
|
||||
"description": "Sovrascrive il limite di righe per prevenire problemi di memoria imponendo un limite rigido alla dimensione di output. Se superato, mantiene l'inizio e la fine e mostra un segnaposto a Roo dove il contenuto viene saltato. <0>Scopri di più</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Dimensione anteprima output comandi",
|
||||
"description": "Controlla quanto output dei comandi Roo vede direttamente. L'output completo viene sempre salvato ed è accessibile quando necessario.",
|
||||
"options": {
|
||||
"small": "Piccola (5KB)",
|
||||
"medium": "Media (10KB)",
|
||||
"large": "Grande (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Timeout integrazione shell terminale",
|
||||
"description": "Quanto tempo attendere l'integrazione della shell di VS Code prima di eseguire i comandi. Aumenta se la tua shell si avvia lentamente o vedi errori 'Integrazione Shell Non Disponibile'. <0>Scopri di più</0>"
|
||||
|
|
@ -737,10 +746,6 @@
|
|||
"label": "Ritardo comando terminale",
|
||||
"description": "Aggiunge una breve pausa dopo ogni comando affinché il terminale VS Code possa svuotare tutto l'output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Usa solo se vedi output finale mancante; altrimenti lascia a 0. <0>Scopri di più</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Comprimi output barra di avanzamento",
|
||||
"description": "Comprime barre di avanzamento/spinner in modo che venga mantenuto solo lo stato finale (risparmia token). <0>Scopri di più</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "Abilita workaround contatore PowerShell",
|
||||
"description": "Attiva quando l'output PowerShell è mancante o duplicato; aggiunge un piccolo contatore a ogni comando per stabilizzare l'output. Mantieni disattivato se l'output sembra già corretto. <0>Scopri di più</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/ja/chat.json
generated
5
webview-ui/src/i18n/locales/ja/chat.json
generated
|
|
@ -340,7 +340,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "新機能:",
|
||||
"worktrees": "Worktrees: Git worktreesで複数のブランチに同時に取り組みます。各worktreeはRoo Codeを備えた独自のVS Codeウィンドウを取得し、ブランチ切り替えなしで並列開発を可能にします。<settingsLink>試す</settingsLink>"
|
||||
"smartCodeFolding": "スマートコードフォールディング: コンテキスト圧縮により、ファイルの軽量マップが保持されるようになりました—関数シグネチャ、クラス宣言、型定義。これにより、圧縮後の継続性が向上し、以前の作業を参照する際にさらにスマートな編集が可能になります。"
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "クラウドの新機能:",
|
||||
|
|
@ -504,5 +504,8 @@
|
|||
"serversPart_other": "{{count}} MCP サーバー",
|
||||
"messageTemplate": "{{servers}}経由で{{tools}}が有効になっています。このような高い数は、モデルを混乱させてエラーを引き起こす可能性があります。{{threshold}}以下に保つようにしてください。",
|
||||
"openMcpSettings": "MCP 設定を開く"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Rooがコマンド出力を読み込みました"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/ja/settings.json
generated
13
webview-ui/src/i18n/locales/ja/settings.json
generated
|
|
@ -725,6 +725,15 @@
|
|||
"label": "ターミナル文字制限",
|
||||
"description": "出力サイズにハードキャップを適用してメモリ問題を防ぐため、行制限を上書きします。超過した場合、最初と最後を保持し、コンテンツがスキップされた箇所にRooにプレースホルダーを表示します。<0>詳細情報</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "コマンド出力プレビューサイズ",
|
||||
"description": "Rooが直接確認できるコマンド出力の量を制御します。完全な出力は常に保存され、必要に応じてアクセス可能です。",
|
||||
"options": {
|
||||
"small": "小 (5KB)",
|
||||
"medium": "中 (10KB)",
|
||||
"large": "大 (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "ターミナルシェル統合タイムアウト",
|
||||
"description": "コマンドを実行する前<E3828B><E5898D><EFBFBD><EFBFBD><EFBFBD>VS Codeシェル統合を待機する時間。シェルが遅く起動する場合や「シェル統合が利用できません」というエラーが表示される場合は、この値を増やしてください。<0>詳細</0>"
|
||||
|
|
@ -737,10 +746,6 @@
|
|||
"label": "ターミナルコマンド遅延",
|
||||
"description": "VS Codeターミナルがすべての出力をフラッシュできるよう、各コマンド後に短い一時停止を追加します(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)。末尾出力が欠落している場合のみ使用;それ以外は0のままにします。<0>詳細情報</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "プログレスバー出力を圧<E38292><E59CA7><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>",
|
||||
"description": "プログレスバー/スピナーを折りたたんで、最終状態のみを保持します(トークンを節約します)。<0>詳細情報</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "PowerShellカウンターの回避策を有効にする",
|
||||
"description": "PowerShellの出力が欠落または重複している場合にこれをオンにします。出力を安定させるために各コマンドに小さなカウンターを追加します。出力がすでに正しい場合はオフのままにします。<0>詳細情報</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/ko/chat.json
generated
5
webview-ui/src/i18n/locales/ko/chat.json
generated
|
|
@ -340,7 +340,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "새로운 기능:",
|
||||
"worktrees": "Worktrees: Git worktrees로 여러 브랜치에서 동시에 작업합니다. 각 worktree는 Roo Code가 있는 자체 VS Code 창을 얻으며, 브랜치 전환 없이 병렬 개발을 가능하게 합니다. <settingsLink>시도해보세요</settingsLink>"
|
||||
"smartCodeFolding": "스마트 코드 폴딩: 컨텍스트 응축이 이제 파일의 경량 맵을 보존합니다—함수 시그니처, 클래스 선언 및 타입 정의를 포함합니다. 이는 응축 후 더 나은 연속성과 이전 작업을 참조할 때 더 스마트한 편집을 제공합니다."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "클라우드의 새로운 기능:",
|
||||
|
|
@ -504,5 +504,8 @@
|
|||
"serversPart_other": "{{count}}개 MCP 서버",
|
||||
"messageTemplate": "{{servers}}를 통해 {{tools}}가 활성화되어 있습니다. 이렇게 많은 수의 도구는 모델을 혼동시키고 오류를 유발할 수 있습니다. {{threshold}} 이하로 유지하도록 노력하세요.",
|
||||
"openMcpSettings": "MCP 설정 열기"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/ko/settings.json
generated
13
webview-ui/src/i18n/locales/ko/settings.json
generated
|
|
@ -725,6 +725,15 @@
|
|||
"label": "터미널 문자 제한",
|
||||
"description": "출력 크기에 대한 엄격한 상한을 적용하여 메모리 문제를 방지하기 위해 줄 제한을 재정의합니다. 초과하면 시작과 끝을 유지하고 내용이 생략된 곳에 Roo에게 자리 표시자를 표시합니다. <0>자세히 알아보기</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "명령 출력 미리보기 크기",
|
||||
"description": "Roo가 직접 보는 명령 출력량을 제어합니다. 전체 출력은 항상 저장되며 필요할 때 액세스할 수 있습니다.",
|
||||
"options": {
|
||||
"small": "작게 (5KB)",
|
||||
"medium": "보통 (10KB)",
|
||||
"large": "크게 (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "터미널 셸 통합 시간 초과",
|
||||
"description": "명령을 실행하기 전에 VS Code 셸 통합을 기다리는 시간입니다. 셸이 느리게 시작되거나 '셸 통합을 사용할 수 없음' 오류가 표시되면 이 값을 늘리십시오. <0>자세히 알아보기</0>"
|
||||
|
|
@ -737,10 +746,6 @@
|
|||
"label": "터미널 명령 지연",
|
||||
"description": "VS Code 터미널이 모든 출력을 플러시할 수 있도록 각 명령 후에 짧은 일시 중지를 추가합니다(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). 누락된 꼬리 출력이 표시되는 경우에만 사용하고, 그렇지 않으면 0으로 둡니다. <0>자세히 알아보기</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "진행률 표시줄 출력 압축",
|
||||
"description": "진행률 표시줄/스피너를 축소하여 최종 상태만 유지합니다(토큰 절약). <0>자세히 알아보기</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "PowerShell 카운터 해결 방법 활성화",
|
||||
"description": "PowerShell 출력이 누락되거나 중복될 때 이 기능을 켜십시오. 출력을 안정화하기 위해 각 명령에 작은 카운터를 추가합니다. 출력이 이미 올바르게 표시되면 이 기능을 끄십시오. <0>자세히 알아보기</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/nl/chat.json
generated
5
webview-ui/src/i18n/locales/nl/chat.json
generated
|
|
@ -317,7 +317,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Wat is er nieuw:",
|
||||
"worktrees": "Worktrees: Werk gelijktijdig aan meerdere branches met Git worktrees. Elke worktree krijgt zijn eigen VS Code-venster met Roo Code, waardoor parallelle ontwikkeling zonder brancheswissel mogelijk is. <settingsLink>Probeer het</settingsLink>"
|
||||
"smartCodeFolding": "Smart Code Folding: Contextcondensatie behoudt nu een lichte kaart van je bestanden—functiehandtekeningen, klasdeclaraties en typedefinities. Dit biedt betere continuïteit na condensatie en slimmere bewerkingen bij verwijzing naar vorig werk."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Nieuw in de Cloud:",
|
||||
|
|
@ -504,5 +504,8 @@
|
|||
"serversPart_other": "{{count}} MCP servers",
|
||||
"messageTemplate": "Je hebt {{tools}} ingeschakeld via {{servers}}. Zoveel tools kunnen het model verwarren en tot fouten leiden. Probeer dit onder {{threshold}} te houden.",
|
||||
"openMcpSettings": "MCP-instellingen openen"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/nl/settings.json
generated
13
webview-ui/src/i18n/locales/nl/settings.json
generated
|
|
@ -725,6 +725,15 @@
|
|||
"label": "Terminal-tekenlimiet",
|
||||
"description": "Overschrijft de regellimiet om geheugenproblemen te voorkomen door een harde limiet op uitvoergrootte af te dwingen. Bij overschrijding behoudt het begin en einde en toont een placeholder aan Roo waar inhoud wordt overgeslagen. <0>Meer informatie</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Grootte opdrachtuitvoer voorvertoning",
|
||||
"description": "Bepaalt hoeveel opdrachtuitvoer Roo direct ziet. Volledige uitvoer wordt altijd opgeslagen en is toegankelijk wanneer nodig.",
|
||||
"options": {
|
||||
"small": "Klein (5KB)",
|
||||
"medium": "Gemiddeld (10KB)",
|
||||
"large": "Groot (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Terminal-shell-integratie timeout",
|
||||
"description": "Hoe lang te wachten op VS Code-shell-integratie voordat commando's worden uitgevoerd. Verhoog als je shell traag opstart of je 'Shell-Integratie Niet Beschikbaar'-fouten ziet. <0>Meer informatie</0>"
|
||||
|
|
@ -737,10 +746,6 @@
|
|||
"label": "Terminal-commandovertraging",
|
||||
"description": "Voegt korte pauze toe na elk commando zodat VS Code-terminal alle uitvoer kan flushen (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Gebruik alleen als je ontbrekende tail-uitvoer ziet; anders op 0 laten. <0>Meer informatie</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Voortgangsbalk-uitvoer comprimeren",
|
||||
"description": "Klapt voortgangsbalken/spinners in zodat alleen eindstatus behouden blijft (bespaart tokens). <0>Meer informatie</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "PowerShell-teller workaround inschakelen",
|
||||
"description": "Schakel in wanneer PowerShell-uitvoer ontbreekt of gedupliceerd wordt; voegt kleine teller toe aan elk commando om uitvoer te stabiliseren. Laat uit als uitvoer al correct lijkt. <0>Meer informatie</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/pl/chat.json
generated
5
webview-ui/src/i18n/locales/pl/chat.json
generated
|
|
@ -340,7 +340,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Co nowego:",
|
||||
"worktrees": "Worktrees: Pracuj na wielu gałęziach jednocześnie za pomocą Git worktrees. Każde worktree ma własne okno VS Code z Roo Code, umożliwiając równoległy rozwój bez przełączania gałęzi. <settingsLink>Spróbuj</settingsLink>"
|
||||
"smartCodeFolding": "Smart Code Folding: Kondensacja kontekstu teraz zachowuje lekką mapę twoich plików—sygnatury funkcji, deklaracje klas i definicje typów. Zapewnia to lepszą ciągłość po kondensacji i mądrzejsze edycje przy odwoływaniu się do poprzedniej pracy."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Nowości w chmurze:",
|
||||
|
|
@ -504,5 +504,8 @@
|
|||
"serversPart_other": "{{count}} serwerów MCP",
|
||||
"messageTemplate": "Masz {{tools}} włączonych przez {{servers}}. Taka duża liczba może zamieszać model i prowadzić do błędów. Staraj się, aby była poniżej {{threshold}}.",
|
||||
"openMcpSettings": "Otwórz ustawienia MCP"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/pl/settings.json
generated
13
webview-ui/src/i18n/locales/pl/settings.json
generated
|
|
@ -725,6 +725,15 @@
|
|||
"label": "Limit znaków terminala",
|
||||
"description": "Zastępuje limit linii, aby zapobiec problemom z pamięcią, narzucając twardy limit rozmiaru wyjścia. W przypadku przekroczenia zachowuje początek i koniec i pokazuje symbol zastępczy Roo tam, gdzie treść jest pomijana. <0>Dowiedz się więcej</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Rozmiar podglądu wyjścia polecenia",
|
||||
"description": "Kontroluje, ile wyjścia polecenia Roo widzi bezpośrednio. Pełne wyjście jest zawsze zapisywane i dostępne w razie potrzeby.",
|
||||
"options": {
|
||||
"small": "Mały (5KB)",
|
||||
"medium": "Średni (10KB)",
|
||||
"large": "Duży (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Limit czasu integracji powłoki terminala",
|
||||
"description": "Jak długo czekać na integrację powłoki VS Code przed wykonaniem poleceń. Zwiększ, jeśli twoja powłoka wolno się uruchamia lub widzisz błędy 'Integracja Powłoki Niedostępna'. <0>Dowiedz się więcej</0>"
|
||||
|
|
@ -737,10 +746,6 @@
|
|||
"label": "Opóźnienie polecenia terminala",
|
||||
"description": "Dodaje krótką pauzę po każdym poleceniu, aby terminal VS Code mógł opróżnić całe wyjście (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Używaj tylko gdy widzisz brakujące wyjście końcowe; w przeciwnym razie zostaw na 0. <0>Dowiedz się więcej</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Kompresuj wyjście paska postępu",
|
||||
"description": "Zwija paski postępu/spinnery, aby zachować tylko stan końcowy (oszczędza tokeny). <0>Dowiedz się więcej</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "Włącz obejście licznika PowerShell",
|
||||
"description": "Włącz gdy brakuje lub jest zduplikowane wyjście PowerShell; dodaje mały licznik do każdego polecenia, aby ustabilizować wyjście. Pozostaw wyłączone, jeśli wyjście już wygląda poprawnie. <0>Dowiedz się więcej</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/pt-BR/chat.json
generated
5
webview-ui/src/i18n/locales/pt-BR/chat.json
generated
|
|
@ -340,7 +340,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Novidades:",
|
||||
"worktrees": "Worktrees: Trabalhe em múltiplas branches simultaneamente com Git worktrees. Cada worktree obtém sua própria janela VS Code com Roo Code, permitindo desenvolvimento paralelo sem troca de branch. <settingsLink>Tente</settingsLink>"
|
||||
"smartCodeFolding": "Smart Code Folding: A condensação de contexto agora preserva um mapa leve de seus arquivos—assinaturas de função, declarações de classe e definições de tipo. Isso oferece melhor continuidade após condensação e edições mais inteligentes ao referenciar trabalho anterior."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Novidades na Nuvem:",
|
||||
|
|
@ -504,5 +504,8 @@
|
|||
"serversPart_other": "{{count}} servidores MCP",
|
||||
"messageTemplate": "Você tem {{tools}} habilitadas via {{servers}}. Um número tão alto pode confundir o modelo e levar a erros. Tente mantê-lo abaixo de {{threshold}}.",
|
||||
"openMcpSettings": "Abrir Configurações MCP"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/pt-BR/settings.json
generated
13
webview-ui/src/i18n/locales/pt-BR/settings.json
generated
|
|
@ -725,6 +725,15 @@
|
|||
"label": "Limite de caracteres do terminal",
|
||||
"description": "Substitui o limite de linhas para evitar problemas de memória, impondo um limite rígido no tamanho da saída. Se excedido, mantém o início e o fim e mostra um placeholder para o Roo onde o conteúdo é pulado. <0>Saiba mais</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Tamanho da visualização da saída de comandos",
|
||||
"description": "Controla quanto da saída de comandos Roo vê diretamente. A saída completa é sempre salva e acessível quando necessário.",
|
||||
"options": {
|
||||
"small": "Pequeno (5KB)",
|
||||
"medium": "Médio (10KB)",
|
||||
"large": "Grande (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Tempo limite de integração do shell do terminal",
|
||||
"description": "Quanto tempo esperar pela integração do shell do VS Code antes de executar comandos. Aumente se o seu shell demorar para iniciar ou se você vir erros de 'Integração do Shell Indisponível'. <0>Saiba mais</0>"
|
||||
|
|
@ -737,10 +746,6 @@
|
|||
"label": "Atraso de comando do terminal",
|
||||
"description": "Adiciona uma pequena pausa após cada comando para que o terminal do VS Code possa liberar toda a saída (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Use apenas se você vir a saída final faltando; caso contrário, deixe em 0. <0>Saiba mais</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Comprimir saída da barra de progresso",
|
||||
"description": "Recolhe barras de progresso/spinners para que apenas o estado final seja mantido (economiza tokens). <0>Saiba mais</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "Ativar solução alternativa do contador do PowerShell",
|
||||
"description": "Ative isso quando a saída do PowerShell estiver faltando ou duplicada; ele adiciona um pequeno contador a cada comando para estabilizar a saída. Mantenha desativado se a saída já parecer correta. <0>Saiba mais</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/ru/chat.json
generated
5
webview-ui/src/i18n/locales/ru/chat.json
generated
|
|
@ -318,7 +318,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Что нового:",
|
||||
"worktrees": "Worktrees: Работайте над несколькими ветками одновременно с Git worktrees. Каждое worktree получает собственное окно VS Code с Roo Code, позволяя параллельную разработку без переключения ветвей. <settingsLink>Попробуйте</settingsLink>"
|
||||
"smartCodeFolding": "Smart Code Folding: Конденсация контекста теперь сохраняет легкую карту ваших файлов—сигнатуры функций, объявления классов и определения типов. Это обеспечивает лучшую непрерывность после конденсации и более умные правки при ссылке на предыдущую работу."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Новое в облаке:",
|
||||
|
|
@ -505,5 +505,8 @@
|
|||
"serversPart_other": "{{count}} серверов MCP",
|
||||
"messageTemplate": "У тебя включено {{tools}} через {{servers}}. Такое большое количество может сбить модель с толку и привести к ошибкам. Постарайся держать это ниже {{threshold}}.",
|
||||
"openMcpSettings": "Открыть настройки MCP"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/ru/settings.json
generated
13
webview-ui/src/i18n/locales/ru/settings.json
generated
|
|
@ -725,6 +725,15 @@
|
|||
"label": "Лимит символов терминала",
|
||||
"description": "Переопределяет лимит строк для предотвращения проблем с памятью, устанавливая жёсткое ограничение на размер вывода. При превышении сохраняет начало и конец и показывает Roo заполнитель там, где контент пропущен. <0>Подробнее</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Размер предпросмотра вывода команд",
|
||||
"description": "Контролирует, сколько вывода команды Roo видит напрямую. Полный вывод всегда сохраняется и доступен при необходимости.",
|
||||
"options": {
|
||||
"small": "Маленький (5KB)",
|
||||
"medium": "Средний (10KB)",
|
||||
"large": "Большой (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Таймаут интеграции shell терминала",
|
||||
"description": "Сколько ждать интеграции shell VS Code перед выполнением команд. Увеличьте, если ваш shell запускается медленно или вы видите ошибки 'Интеграция Shell Недоступна'. <0>Подробнее</0>"
|
||||
|
|
@ -737,10 +746,6 @@
|
|||
"label": "Задержка команды терминала",
|
||||
"description": "Добавляет короткую паузу после каждой команды, чтобы терминал VS Code мог вывести весь output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Используйте только если видите отсутствующий tail output; иначе оставьте 0. <0>Подробнее</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Сжимать вывод прогресс-бара",
|
||||
"description": "Сворачивает прогресс-бары/спиннеры, чтобы сохранялось только финальное состояние (экономит токены). <0>Подробнее</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "Включить обходчик счётчика PowerShell",
|
||||
"description": "Включите, когда вывод PowerShell отсутствует или дублируется; добавляет маленький счётчик к каждой команде для стабилизации вывода. Оставьте выключенным, если вывод уже выглядит корректно. <0>Подробнее</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/tr/chat.json
generated
5
webview-ui/src/i18n/locales/tr/chat.json
generated
|
|
@ -341,7 +341,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Yenilikler:",
|
||||
"worktrees": "Worktrees: Git worktrees ile aynı anda birden fazla şubede çalışın. Her worktree, Roo Code'lu kendi VS Code penceresini alır ve şube değiştirmeden paralel geliştirmeyi sağlar. <settingsLink>Deneyin</settingsLink>"
|
||||
"smartCodeFolding": "Smart Code Folding: Bağlam yoğunlaştırması şimdi dosyalarınızın hafif bir haritasını korur—fonksiyon imzaları, sınıf bildirimleri ve tür tanımları. Bu, yoğunlaştırmadan sonra daha iyi devamlılık ve önceki çalışmaya atıfta bulunurken daha akıllı düzenlemeler sağlar."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Cloud'daki yenilikler:",
|
||||
|
|
@ -505,5 +505,8 @@
|
|||
"serversPart_other": "{{count}} MCP sunucusu",
|
||||
"messageTemplate": "{{servers}} üzerinden {{tools}} etkinleştirilmiş durumda. Bu kadar fazlası modeli kafası karışabilir ve hatalara neden olabilir. {{threshold}} altında tutmaya çalış.",
|
||||
"openMcpSettings": "MCP Ayarlarını Aç"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/tr/settings.json
generated
13
webview-ui/src/i18n/locales/tr/settings.json
generated
|
|
@ -725,6 +725,15 @@
|
|||
"label": "Terminal karakter sınırı",
|
||||
"description": "Çıktı boyutuna katı bir üst sınır uygulayarak bellek sorunlarını önlemek için satır sınırını geçersiz kılar. Aşılırsa, başlangıcı ve sonu tutar ve içeriğin atlandığı yerde Roo'ya bir yer tutucu gösterir. <0>Daha fazla bilgi edinin</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Komut çıktısı önizleme boyutu",
|
||||
"description": "Roo'nun doğrudan gördüğü komut çıktısı miktarını kontrol eder. Tam çıktı her zaman kaydedilir ve gerektiğinde erişilebilir.",
|
||||
"options": {
|
||||
"small": "Küçük (5KB)",
|
||||
"medium": "Orta (10KB)",
|
||||
"large": "Büyük (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Terminal shell entegrasyon timeout",
|
||||
"description": "Komut çalıştırmadan önce VS Code shell entegrasyonunu bekleme süresi. Shell yavaş başlıyorsa veya 'Shell Integration Unavailable' hatası görüyorsanız artırın. <0>Daha fazla bilgi edinin</0>"
|
||||
|
|
@ -737,10 +746,6 @@
|
|||
"label": "Terminal komut delay",
|
||||
"description": "VS Code terminalin tüm outputu flush edebilmesi için her komuttan sonra kısa pause ekler (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Sadece tail output eksikse kullan; yoksa 0'da bırak. <0>Daha fazla bilgi edinin</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "İlerleme çubuğu çıktısını sıkıştır",
|
||||
"description": "İlerleme çubukları/spinner'ları daraltır, sadece son durumu tutar (token tasarrufu). <0>Daha fazla bilgi edinin</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "PowerShell sayaç geçici çözümünü etkinleştir",
|
||||
"description": "PowerShell çıktısı eksik veya yineleniyorsa bunu açın; çıktıyı stabilize etmek için her komuta küçük bir sayaç ekler. Çıktı zaten doğru görünüyorsa bunu kapalı tutun. <0>Daha fazla bilgi edinin</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/vi/chat.json
generated
5
webview-ui/src/i18n/locales/vi/chat.json
generated
|
|
@ -341,7 +341,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "Tính năng mới:",
|
||||
"worktrees": "Worktrees: Làm việc trên nhiều nhánh cùng lúc với Git worktrees. Mỗi worktree có cửa sổ VS Code riêng với Roo Code, cho phép phát triển song song mà không cần chuyển nhánh. <settingsLink>Hãy thử</settingsLink>"
|
||||
"smartCodeFolding": "Smart Code Folding: Nén ngữ cảnh giờ đây bảo tồn một bản đồ nhẹ của các tệp của bạn—chữ ký hàm, khai báo lớp và định nghĩa kiểu. Điều này cung cấp tính liên tục tốt hơn sau nén và chỉnh sửa thông minh hơn khi tham chiếu công việc trước đó."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "Mới trên Cloud:",
|
||||
|
|
@ -505,5 +505,8 @@
|
|||
"serversPart_other": "{{count}} máy chủ MCP",
|
||||
"messageTemplate": "Bạn đã bật {{tools}} qua {{servers}}. Số lượng lớn như vậy có thể khiến mô hình bối rối và dẫn đến lỗi. Cố gắng giữ nó dưới {{threshold}}.",
|
||||
"openMcpSettings": "Mở cài đặt MCP"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/vi/settings.json
generated
13
webview-ui/src/i18n/locales/vi/settings.json
generated
|
|
@ -725,6 +725,15 @@
|
|||
"label": "Giới hạn ký tự terminal",
|
||||
"description": "Ghi đè giới hạn dòng để tránh vấn đề bộ nhớ bằng cách áp đặt giới hạn cứng cho kích thước đầu ra. Nếu vượt quá, giữ đầu và cuối, hiển thị placeholder cho Roo nơi nội dung bị bỏ qua. <0>Tìm hiểu thêm</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "Kích thước xem trước đầu ra lệnh",
|
||||
"description": "Kiểm soát lượng đầu ra lệnh mà Roo nhìn thấy trực tiếp. Đầu ra đầy đủ luôn được lưu và có thể truy cập khi cần thiết.",
|
||||
"options": {
|
||||
"small": "Nhỏ (5KB)",
|
||||
"medium": "Trung bình (10KB)",
|
||||
"large": "Lớn (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "Timeout tích hợp shell terminal",
|
||||
"description": "Thời gian đợi tích hợp shell VS Code trước khi chạy lệnh. Tăng nếu shell khởi động chậm hoặc thấy lỗi 'Shell Integration Unavailable'. <0>Tìm hiểu thêm</0>"
|
||||
|
|
@ -737,10 +746,6 @@
|
|||
"label": "Delay lệnh terminal",
|
||||
"description": "Thêm khoảng dừng ngắn sau mỗi lệnh để VS Code terminal flush tất cả output (bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep). Chỉ dùng nếu thiếu tail output; nếu không để ở 0. <0>Tìm hiểu thêm</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "Nén đầu ra thanh tiến trình",
|
||||
"description": "Thu gọn các thanh tiến trình/vòng quay để chỉ giữ lại trạng thái cuối cùng (tiết kiệm token). <0>Tìm hiểu thêm</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "Bật workaround bộ đếm PowerShell",
|
||||
"description": "Bật khi output PowerShell thiếu hoặc trùng lặp; thêm counter nhỏ vào mỗi lệnh để ổn định output. Tắt nếu output đã đúng. <0>Tìm hiểu thêm</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/zh-CN/chat.json
generated
5
webview-ui/src/i18n/locales/zh-CN/chat.json
generated
|
|
@ -341,7 +341,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "新增功能:",
|
||||
"worktrees": "Worktrees:使用 Git worktrees 同时在多个分支上工作。每个 worktree 都有自己的 VS Code 窗口和 Roo Code,可以实现无需切换分支的并行开发。<settingsLink>试试看</settingsLink>"
|
||||
"smartCodeFolding": "智能代码折叠:上下文压缩现在保留文件的轻量级映射——函数签名、类声明和类型定义。 这在压缩后提供更好的连续性,引用之前工作时编辑更聪明。"
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "云端新功能:",
|
||||
|
|
@ -505,5 +505,8 @@
|
|||
"serversPart_other": "{{count}} 个 MCP 服务",
|
||||
"messageTemplate": "你通过 {{servers}} 启用了 {{tools}}。这么多数量会混淆模型并导致错误。建议将其保持在 {{threshold}} 以下。",
|
||||
"openMcpSettings": "打开 MCP 设置"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/zh-CN/settings.json
generated
13
webview-ui/src/i18n/locales/zh-CN/settings.json
generated
|
|
@ -725,6 +725,15 @@
|
|||
"label": "终端字符限制",
|
||||
"description": "通过强制限制输出大小来覆盖行限制以防止内存问题。如果超出,保留开头和结尾并向 Roo 显示内容被跳过的占位符。<0>了解更多</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "命令输出预览大小",
|
||||
"description": "控制 Roo 直接看到的命令输出量。完整输出始终会被保存,需要时可以访问。",
|
||||
"options": {
|
||||
"small": "小 (5KB)",
|
||||
"medium": "中 (10KB)",
|
||||
"large": "大 (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "终端 shell 集成超时",
|
||||
"description": "运行命令前等待 VS Code shell 集成的时间。如果 shell 启动缓慢或看到 'Shell Integration Unavailable' 错误,请提高此值。<0>了解更多</0>"
|
||||
|
|
@ -737,10 +746,6 @@
|
|||
"label": "终端命令延迟",
|
||||
"description": "在每个命令后添加短暂暂停,以便 VS Code 终端刷新所有输出(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)。仅在看到缺少尾部输出时使用;否则保持为 0。<0>了解更多</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "压缩进度条输出",
|
||||
"description": "折叠进度条/旋转器,仅保留最终状态(节省 token)。<0>了解更多</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "启用 PowerShell 计数器解决方案",
|
||||
"description": "当 PowerShell 输出丢失或重复时启用此选项;它会为每个命令附加一个小计数器以稳定输出。如果输出已正常,请保持关闭。<0>了解更多</0>"
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/zh-TW/chat.json
generated
5
webview-ui/src/i18n/locales/zh-TW/chat.json
generated
|
|
@ -346,7 +346,7 @@
|
|||
},
|
||||
"release": {
|
||||
"heading": "新增功能:",
|
||||
"worktrees": "Worktrees:使用 Git worktrees 在多個分支上同時工作。每個 worktree 都配有自己的 VS Code 視窗和 Roo Code,實現無需切換分支的平行開發。<settingsLink>試試看</settingsLink>"
|
||||
"smartCodeFolding": "智慧代碼摺疊:上下文壓縮現保留檔案的輕量級對應圖——函數簽章、類別宣告和型別定義。 這提供壓縮後更佳的連續性,以及引用之前工作時更聰慧的編輯。"
|
||||
},
|
||||
"cloudAgents": {
|
||||
"heading": "雲端的新功能:",
|
||||
|
|
@ -495,5 +495,8 @@
|
|||
"serversPart_other": "{{count}} 個 MCP 伺服器",
|
||||
"messageTemplate": "您已啟用 {{tools}}(透過 {{servers}})。這麼多的工具可能會混淆模型並導致錯誤。請嘗試保持在 {{threshold}} 以下。",
|
||||
"openMcpSettings": "開啟 MCP 設定"
|
||||
},
|
||||
"readCommandOutput": {
|
||||
"title": "Roo read command output"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
webview-ui/src/i18n/locales/zh-TW/settings.json
generated
13
webview-ui/src/i18n/locales/zh-TW/settings.json
generated
|
|
@ -733,6 +733,15 @@
|
|||
"label": "終端機字元限制",
|
||||
"description": "透過強制限制輸出大小來覆寫行限制以防止記憶體問題。如果超出,保留開頭和結尾並向 Roo 顯示內容被跳過的佔位符。<0>了解更多</0>"
|
||||
},
|
||||
"outputPreviewSize": {
|
||||
"label": "命令輸出預覽大小",
|
||||
"description": "控制 Roo 直接看到的命令輸出量。完整輸出始終會被儲存,需要時可以存取。",
|
||||
"options": {
|
||||
"small": "小 (5KB)",
|
||||
"medium": "中 (10KB)",
|
||||
"large": "大 (20KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
"label": "終端機 shell 整合逾時",
|
||||
"description": "執行命令前等待 VS Code shell 整合的時間。如果 shell 啟動緩慢或看到 'Shell Integration Unavailable' 錯誤,請提高此值。<0>了解更多</0>"
|
||||
|
|
@ -745,10 +754,6 @@
|
|||
"label": "終端機命令延遲",
|
||||
"description": "在每個命令後新增短暫暫停,以便 VS Code 終端機刷新所有輸出(bash/zsh: PROMPT_COMMAND sleep; PowerShell: start-sleep)。僅在看到缺少尾部輸出時使用;否則保持為 0。<0>了解更多</0>"
|
||||
},
|
||||
"compressProgressBar": {
|
||||
"label": "壓縮進度條輸出",
|
||||
"description": "折疊進度條/旋轉器,僅保留最終狀態(節省 Token)。<0>了解更多</0>"
|
||||
},
|
||||
"powershellCounter": {
|
||||
"label": "啟用 PowerShell 計數器解決方案",
|
||||
"description": "當 PowerShell 輸出遺失或重複時啟用此選項;它會為每個命令附加一個小計數器以穩定輸出。如果輸出已正常,請保持關閉。<0>了解更多</0>"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue