diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9e22303a35..34ac70a6cf 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -76,7 +76,7 @@ body: label: API Provider (optional) options: - Anthropic - - AWS Bedrock + - Amazon Bedrock - Chutes AI - DeepSeek - Featherless AI diff --git a/.gitignore b/.gitignore index e044fc32a7..54cf66cee7 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ logs # Qdrant qdrant_storage/ + +# Architect plans +plans/ \ No newline at end of file diff --git a/.roo/commands/release.md b/.roo/commands/release.md index 8adf57e6f0..707844cdc4 100644 --- a/.roo/commands/release.md +++ b/.roo/commands/release.md @@ -16,14 +16,14 @@ argument-hint: patch | minor | major [list of changes] ``` -- Always include contributor attribution using format: (thanks @username!) -- For PRs that close issues, also include the issue number and reporter: "- Fix: Description (#123 by @reporter, PR by @contributor)" -- For PRs without linked issues, use the standard format: "- Add support for feature (thanks @contributor!)" +- Always include contributor attribution and the PR number: use "(PR # by @username)". +- For PRs that close issues, include both the issue number and the PR number and authors: "- Fix: Description (#123 by @reporter, PR #456 by @contributor)" +- For PRs without linked issues, include the PR number and author: "- Add support for feature (PR #456 by @contributor)" - Provide brief descriptions of each item to explain the change - Order the list from most important to least important - Example formats: - - With issue: "- Fix: Resolve memory leak in extension (#456 by @issueReporter, PR by @prAuthor)" - - Without issue: "- Add support for Gemini 2.5 Pro caching (thanks @contributor!)" + - With issue: "- Fix: Resolve memory leak in extension (#456 by @issueReporter, PR #789 by @prAuthor)" + - Without issue: "- Add support for Gemini 2.5 Pro caching (PR #789 by @contributor)" - CRITICAL: Include EVERY SINGLE PR in the changeset - don't assume you know which ones are important. Count the total PRs to verify completeness and cross-reference the list to ensure nothing is missed. 6. If the generate_image tool is available, create a release image at `releases/[version]-release.png` diff --git a/.roo/rules-translate/instructions-zh-cn.md b/.roo/rules-translate/instructions-zh-cn.md index 241ae338dc..6141038728 100644 --- a/.roo/rules-translate/instructions-zh-cn.md +++ b/.roo/rules-translate/instructions-zh-cn.md @@ -115,7 +115,7 @@ - 保留英文品牌名 - 技术术语保持一致性 - - 保留英文专有名词:如"AWS Bedrock ARN" + - 保留英文专有名词:如"Amazon Bedrock ARN" 4. **用户操作** - 操作动词统一: diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d1d5dd32..964cc042ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,351 @@ # Roo Code Changelog +## [3.36.3] - 2025-12-09 + +![3.36.3 Release](/releases/3.36.3-release.png) + +- Refactor: Unified context-management architecture with improved UX for better context control (PR #9795 by @hannesrudolph) +- Add new `search_replace` native tool for single-replacement operations with improved editing precision (PR #9918 by @hannesrudolph) +- Streaming tool stats and token usage throttling for better real-time feedback during generation (PR #9926 by @hannesrudolph) +- Add versioned settings support with minPluginVersion gating for Roo provider (PR #9934 by @hannesrudolph) +- Make Architect mode save plans to `/plans` directory and gitignore it (PR #9944 by @brunobergher) +- Add announcement support CTA and social icons to UI (PR #9945 by @hannesrudolph) +- Add ability to save screenshots from the browser tool (PR #9963 by @mrubens) +- Refactor: Decouple tools from system prompt for cleaner architecture (PR #9784 by @daniel-lxs) +- Update DeepSeek models to V3.2 with new pricing (PR #9962 by @hannesrudolph) +- Add minimal and medium reasoning effort levels for Gemini models (PR #9973 by @hannesrudolph) +- Update xAI models catalog with latest model options (PR #9872 by @hannesrudolph) +- Add DeepSeek V3-2 support for Baseten provider (PR #9861 by @AlexKer) +- Tweaks to Baseten model definitions for better defaults (PR #9866 by @mrubens) +- Fix: Add xhigh reasoning effort support for gpt-5.1-codex-max (#9891 by @andrewginns, PR #9900 by @andrewginns) +- Fix: Add Kimi, MiniMax, and Qwen model configurations for Bedrock (#9902 by @jbearak, PR #9905 by @app/roomote) +- Configure tool preferences for xAI models (PR #9923 by @hannesrudolph) +- Default to using native tools when supported on OpenRouter (PR #9878 by @mrubens) +- Fix: Exclude apply_diff from native tools when diffEnabled is false (#9919 by @denis-kudelin, PR #9920 by @app/roomote) +- Fix: Always show tool protocol selector for openai-compatible provider (#9965 by @bozoweed, PR #9966 by @hannesrudolph) +- Fix: Respect explicit supportsReasoningEffort array values for proper model configuration (PR #9970 by @hannesrudolph) +- Add timeout configuration to OpenAI Compatible Provider Client (PR #9898 by @dcbartlett) +- Revert default tool protocol change from xml to native for stability (PR #9956 by @mrubens) +- Remove defaultTemperature from Roo provider configuration (PR #9932 by @mrubens) +- Improve OpenAI error messages to be more useful for debugging (PR #9639 by @mrubens) +- Better error logs for parseToolCall exceptions (PR #9857 by @cte) +- Improve cloud job error logging for RCC provider errors (PR #9924 by @cte) +- Fix: Display actual API error message instead of generic text on retry (PR #9954 by @hannesrudolph) +- Add API error telemetry to OpenRouter provider for better diagnostics (PR #9953 by @daniel-lxs) +- Fix: Sanitize removed/invalid API providers to prevent infinite loop (PR #9869 by @hannesrudolph) +- Fix: Use foreground color for context-management icons (PR #9912 by @hannesrudolph) +- Fix: Suppress 'ask promise was ignored' error in handleError (PR #9914 by @daniel-lxs) +- Fix: Process finish_reason to emit tool_call_end events properly (PR #9927 by @daniel-lxs) +- Fix: Add finish_reason processing to xai.ts provider (PR #9929 by @daniel-lxs) +- Fix: Validate and fix tool_result IDs before API requests (PR #9952 by @daniel-lxs) +- Fix: Return undefined instead of 0 for disabled API timeout (PR #9960 by @hannesrudolph) +- Stop making unnecessary count_tokens requests for better performance (PR #9884 by @mrubens) +- Refactor: Consolidate ThinkingBudget components and fix disable handling (PR #9930 by @hannesrudolph) +- Forbid time estimates in architect mode for more focused planning (PR #9931 by @app/roomote) +- Web: Add product pages (PR #9865 by @brunobergher) +- Make eval runs deleteable in the web UI (PR #9909 by @mrubens) +- Feat: Change defaultToolProtocol default from xml to native (later reverted) (PR #9892 by @app/roomote) + +## [3.36.2] - 2025-12-04 + +![3.36.2 Release - Dynamic API Settings](/releases/3.36.2-release.png) + +- Restrict GPT-5 tool set to apply_patch for improved compatibility (PR #9853 by @hannesrudolph) +- Add dynamic settings support for Roo models from API, allowing model-specific configurations to be fetched dynamically (PR #9852 by @hannesrudolph) +- Fix: Resolve Chutes provider model fetching issue (PR #9854 by @cte) + +## [3.36.1] - 2025-12-04 + +![3.36.1 Release - Message Management & Stability Improvements](/releases/3.36.1-release.png) + +- Add MessageManager layer for centralized history coordination, fixing message synchronization issues (PR #9842 by @hannesrudolph) +- Fix: Prevent cascading truncation loop by only truncating visible messages (PR #9844 by @hannesrudolph) +- Fix: Handle unknown/invalid native tool calls to prevent extension freeze (PR #9834 by @daniel-lxs) +- Always enable reasoning for models that require it (PR #9836 by @cte) +- ChatView: Smoother stick-to-bottom behavior during streaming (PR #8999 by @hannesrudolph) +- UX: Improved error messages and documentation links (PR #9777 by @brunobergher) +- Fix: Overly round follow-up question suggestions styling (PR #9829 by @brunobergher) +- Add symlink support for slash commands in .roo/commands folder (PR #9838 by @mrubens) +- Ignore input to the execa terminal process for safer command execution (PR #9827 by @mrubens) +- Be safer about large file reads (PR #9843 by @jr) +- Add gpt-5.1-codex-max model to OpenAI provider (PR #9848 by @hannesrudolph) +- Evals UI: Add filtering, bulk delete, tool consolidation, and run notes (PR #9837 by @hannesrudolph) +- Evals UI: Add multi-model launch and UI improvements (PR #9845 by @hannesrudolph) +- Web: New pricing page (PR #9821 by @brunobergher) + +## [3.36.0] - 2025-12-04 + +![3.36.0 Release - Rewind Kangaroo](/releases/3.36.0-release.png) + +- Fix: Restore context when rewinding after condense (#8295 by @hannesrudolph, PR #9665 by @hannesrudolph) +- Add reasoning_details support to Roo provider for enhanced model reasoning visibility (PR #9796 by @app/roomote) +- Default to native tools for all models in the Roo provider for improved performance (PR #9811 by @mrubens) +- Enable search_and_replace for Minimax models (PR #9780 by @mrubens) +- Fix: Resolve Vercel AI Gateway model fetching issues (PR #9791 by @cte) +- Fix: Apply conservative max tokens for Cerebras provider (PR #9804 by @sebastiand-cerebras) +- Fix: Remove omission detection logic to eliminate false positives (#9785 by @Michaelzag, PR #9787 by @app/roomote) +- Refactor: Remove deprecated insert_content tool (PR #9751 by @daniel-lxs) +- Chore: Hide parallel tool calls experiment and disable feature (PR #9798 by @hannesrudolph) +- Update next.js documentation site dependencies (PR #9799 by @jr) +- Fix: Correct download count display on homepage (PR #9807 by @mrubens) + +## [3.35.5] - 2025-12-03 + +- Feat: Add provider routing selection for OpenRouter embeddings (#9144 by @SannidhyaSah, PR #9693 by @SannidhyaSah) +- Default Minimax M2 to native tool calling (PR #9778 by @mrubens) +- Sanitize the native tool calls to fix a bug with Gemini (PR #9769 by @mrubens) +- UX: Updates to CloudView (PR #9776 by @roomote) + +## [3.35.4] - 2025-12-02 + +- Fix: Handle malformed native tool calls to prevent hanging (PR #9758 by @daniel-lxs) +- Fix: Remove reasoning toggles for GLM-4.5 and GLM-4.6 on z.ai provider (PR #9752 by @roomote) +- Refactor: Remove line_count parameter from write_to_file tool (PR #9667 by @hannesrudolph) + +## [3.35.3] - 2025-12-02 + +- Switch to new welcome view for improved onboarding experience (PR #9741 by @mrubens) +- Update homepage with latest changes (PR #9675 by @brunobergher) +- Improve privacy for stealth models by adding vendor confidentiality section to system prompt (PR #9742 by @mrubens) + +## [3.35.2] - 2025-12-01 + +![3.35.2 Release - Model Default Temperatures](/releases/3.35.2-release.png) + +- Allow models to contain default temperature settings for provider-specific optimal defaults (PR #9734 by @mrubens) +- Add tag-based native tool calling detection for Roo provider models (PR #9735 by @mrubens) +- Enable native tool support for all LiteLLM models by default (PR #9736 by @mrubens) +- Pass app version to provider for improved request tracking (PR #9730 by @cte) + +## [3.35.1] - 2025-12-01 + +- Fix: Flush pending tool results before task delegation (PR #9726 by @daniel-lxs) +- Improve: Better IPC error logging for easier debugging (PR #9727 by @cte) + +## [3.35.0] - 2025-12-01 + +![3.35.0 Release - Subtasks & Native Tools](/releases/3.35.0-release.png) + +- Metadata-driven subtasks with automatic parent resume and single-open safety for improved task orchestration (#8081 by @hannesrudolph, PR #9090 by @hannesrudolph) +- Native tool calling support expanded across many providers: Bedrock (PR #9698 by @mrubens), Cerebras (PR #9692 by @mrubens), Chutes with auto-detection from API (PR #9715 by @daniel-lxs), DeepInfra (PR #9691 by @mrubens), DeepSeek and Doubao (PR #9671 by @daniel-lxs), Groq (PR #9673 by @daniel-lxs), LiteLLM (PR #9719 by @daniel-lxs), Ollama (PR #9696 by @mrubens), OpenAI-compatible providers (PR #9676 by @daniel-lxs), Requesty (PR #9672 by @daniel-lxs), Unbound (PR #9699 by @mrubens), Vercel AI Gateway (PR #9697 by @mrubens), Vertex Gemini (PR #9678 by @daniel-lxs), and xAI with new Grok 4 Fast and Grok 4.1 Fast models (PR #9690 by @mrubens) +- Fix: Preserve tool_use blocks in summary for parallel tool calls (#9700 by @SilentFlower, PR #9714 by @SilentFlower) +- Default Grok Code Fast to native tools for better performance (PR #9717 by @mrubens) +- UX improvements to the Roo Code Cloud provider-centric onboarding flow (PR #9709 by @brunobergher) +- UX toolbar cleanup and settings consolidation for a cleaner interface (PR #9710 by @brunobergher) +- Add model-specific tool customization via `excludedTools` and `includedTools` configuration (PR #9641 by @daniel-lxs) +- Add new `apply_patch` native tool for more efficient file editing operations (PR #9663 by @hannesrudolph) +- Add new `search_and_replace` tool for batch text replacements across files (PR #9549 by @hannesrudolph) +- Add debug buttons to view API and UI history for troubleshooting (PR #9684 by @hannesrudolph) +- Include tool format in environment details for better context awareness (PR #9661 by @mrubens) +- Fix: Display install count in millions instead of thousands (PR #9677 by @app/roomote) +- Web-evals improvements: add task log viewing, export failed logs, and new run options (PR #9637 by @hannesrudolph) +- Web-evals updates: add kill run functionality (PR #9681 by @hannesrudolph) +- Fix: Prevent navigation buttons from wrapping on smaller screens (PR #9721 by @app/roomote) + +## [3.34.8] - 2025-11-27 + +![3.34.8 Release - Race Condition Fix](/releases/3.34.8-release.png) + +- Fix: Race condition in new_task tool for native protocol (PR #9655 by @daniel-lxs) + +## [3.34.7] - 2025-11-27 + +![3.34.7 Release - More Native Tool Integrations](/releases/3.34.7-release.png) + +- Support native tools in the Anthropic provider for improved tool calling (PR #9644 by @mrubens) +- Enable native tool calling for z.ai models (PR #9645 by @mrubens) +- Enable native tool calling for Moonshot models (PR #9646 by @mrubens) +- Fix: OpenRouter tool calls handling improvements (PR #9642 by @mrubens) +- Fix: OpenRouter GPT-5 strict schema validation for read_file tool (PR #9633 by @daniel-lxs) +- Fix: Create parent directories early in write_to_file to prevent ENOENT errors (#9634 by @ivanenev, PR #9640 by @daniel-lxs) +- Fix: Disable native tools and temperature support for claude-code provider (PR #9643 by @hannesrudolph) +- Add 'taking you to cloud' screen after provider welcome for improved onboarding (PR #9652 by @mrubens) + +## [3.34.6] - 2025-11-26 + +![3.34.6 Release - Bedrock Embeddings](/releases/3.34.6-release.png) + +- Add support for AWS Bedrock embeddings in code indexing (#8658 by @kyle-hobbs, PR #9475 by @ggoranov-smar) +- Add native tool calling support for Mistral provider (PR #9625 by @hannesrudolph) +- Wire MULTIPLE_NATIVE_TOOL_CALLS experiment to OpenAI parallel_tool_calls for parallel tool execution (PR #9621 by @hannesrudolph) +- Add fine grained tool streaming for OpenRouter Anthropic (PR #9629 by @mrubens) +- Allow global inference selection for Bedrock when cross-region is enabled (PR #9616 by @roomote) +- Fix: Filter non-Anthropic content blocks before sending to Vertex API (#9583 by @cardil, PR #9618 by @hannesrudolph) +- Fix: Restore content undefined check in WriteToFileTool.handlePartial() (#9611 by @Lissanro, PR #9614 by @daniel-lxs) +- Fix: Prevent model cache from persisting empty API responses (#9597 by @zx2021210538, PR #9623 by @daniel-lxs) +- Fix: Exclude access_mcp_resource tool when MCP has no resources (PR #9615 by @daniel-lxs) +- Fix: Update default settings for inline terminal and codebase indexing (PR #9622 by @roomote) +- Fix: Convert line_ranges strings to lineRanges objects in native tool calls (PR #9627 by @daniel-lxs) +- Fix: Defer new_task tool_result until subtask completes for native protocol (PR #9628 by @daniel-lxs) + +## [3.34.5] - 2025-11-25 + +![3.34.5 Release - Experimental Parallel Tool Calling](/releases/3.34.5-release.png) + +- Experimental feature to enable multiple native tool calls per turn (PR #9273 by @daniel-lxs) +- Add Bedrock Opus 4.5 to global inference model list (PR #9595 by @roomote) +- Fix: Update API handler when toolProtocol changes (PR #9599 by @mrubens) +- Set native tools as default for minimax-m2 and claude-haiku-4.5 (PR #9586 by @daniel-lxs) +- Make single file read only apply to XML tools (PR #9600 by @mrubens) +- Enhance web-evals dashboard with dynamic tool columns and UX improvements (PR #9592 by @hannesrudolph) +- Revert "Add support for Roo Code Cloud as an embeddings provider" while we fix some issues (PR #9602 by @mrubens) + +## [3.34.4] - 2025-11-25 + +![3.34.4 Release - BFL Image Generation](/releases/3.34.4-release.png) + +- Add new Black Forest Labs image generation models, free on Roo Code Cloud and also available on OpenRouter (PR #9587 and #9589 by @mrubens) +- Fix: Preserve dynamic MCP tool names in native mode API history to prevent tool name mismatches (PR #9559 by @daniel-lxs) +- Fix: Preserve tool_use blocks in summary message during condensing with native tools to maintain conversation context (PR #9582 by @daniel-lxs) + +## [3.34.3] - 2025-11-25 + +![3.34.3 Release - Streaming and Opus 4.5](/releases/3.34.3-release.png) + +- Implement streaming for native tool calls, providing real-time feedback during tool execution (PR #9542 by @daniel-lxs) +- Add Claude Opus 4.5 model to Claude Code provider (PR #9560 by @mrubens) +- Add Claude Opus 4.5 model to Bedrock provider (#9571 by @pisicode, PR #9572 by @roomote) +- Enable caching for Opus 4.5 model to improve performance (#9567 by @iainRedro, PR #9568 by @roomote) +- Add support for Roo Code Cloud as an embeddings provider (PR #9543 by @mrubens) +- Fix ask_followup_question streaming issue and add missing tool cases (PR #9561 by @daniel-lxs) +- Add contact links to About Roo Code settings page (PR #9570 by @roomote) +- Switch from asdf to mise-en-place in bare-metal evals setup script (PR #9548 by @cte) + +## [3.34.2] - 2025-11-24 + +![3.34.2 Release - Opus Conductor](/releases/3.34.2-release.png) + +- Add support for Claude Opus 4.5 in Anthropic and Vertex providers (PR #9541 by @daniel-lxs) +- Add support for Claude Opus 4.5 in OpenRouter with prompt caching and reasoning budget (PR #9540 by @daniel-lxs) +- Add Roo Code Cloud as an image generation provider (PR #9528 by @mrubens) +- Fix: Gracefully skip unsupported content blocks in Gemini transformer (PR #9537 by @daniel-lxs) +- Fix: Flush LiteLLM cache when credentials change on refresh (PR #9536 by @daniel-lxs) +- Fix: Ensure XML parser state matches tool protocol on config update (PR #9535 by @daniel-lxs) +- Update Cerebras models (PR #9527 by @sebastiand-cerebras) +- Fix: Support reasoning_details format for Gemini 3 models (PR #9506 by @daniel-lxs) + +## [3.34.1] - 2025-11-23 + +- Show the prompt for image generation in the UI (PR #9505 by @mrubens) +- Fix double todo list display issue (PR #9517 by @mrubens) +- Add tracking for cloud synced messages (PR #9518 by @mrubens) +- Enable the Roo Code Cloud provider in evals (PR #9492 by @cte) + +## [3.34.0] - 2025-11-21 + +![3.34.0 Release - Browser Use 2.0](/releases/3.34.0-release.png) + +- Add Browser Use 2.0 with enhanced browser interaction capabilities (PR #8941 by @hannesrudolph) +- Add support for Baseten as a new AI provider (PR #9461 by @AlexKer) +- Improve base OpenAI compatible provider with better error handling and configuration (PR #9462 by @mrubens) +- Add provider-oriented welcome screen to improve onboarding experience (PR #9484 by @mrubens) +- Pin Roo provider to the top of the provider list for better discoverability (PR #9485 by @mrubens) +- Enhance native tool descriptions with examples and clarifications for better AI understanding (PR #9486 by @daniel-lxs) +- Fix: Make cancel button immediately responsive during streaming (#9435 by @jwadow, PR #9448 by @daniel-lxs) +- Fix: Resolve apply_diff performance regression from earlier changes (PR #9474 by @daniel-lxs) +- Fix: Implement model cache refresh to prevent stale disk cache issues (PR #9478 by @daniel-lxs) +- Fix: Copy model-level capabilities to OpenRouter endpoint models correctly (PR #9483 by @daniel-lxs) +- Fix: Add fallback to yield tool calls regardless of finish_reason (PR #9476 by @daniel-lxs) + +## [3.33.3] - 2025-11-20 + +![3.33.3 Release - Gemini 3 Pro Image Preview](/releases/3.33.3-release.png) + +- Add Google Gemini 3 Pro Image Preview to image generation models (PR #9440 by @app/roomote) +- Add support for Minimax as Anthropic-compatible provider (PR #9455 by @daniel-lxs) +- Store reasoning in conversation history for all providers (PR #9451 by @daniel-lxs) +- Fix: Improve preserveReasoning flag to control API reasoning inclusion (PR #9453 by @daniel-lxs) +- Fix: Prevent OpenAI Native parallel tool calls for native tool calling (PR #9433 by @hannesrudolph) +- Fix: Improve search and replace symbol parsing (PR #9456 by @daniel-lxs) +- Fix: Send tool_result blocks for skipped tools in native protocol (PR #9457 by @daniel-lxs) +- Fix: Improve markdown formatting and add reasoning support (PR #9458 by @daniel-lxs) +- Fix: Prevent duplicate environment_details when resuming cancelled tasks (PR #9442 by @daniel-lxs) +- Improve read_file tool description with examples (PR #9422 by @daniel-lxs) +- Update glob dependency to ^11.1.0 (PR #9449 by @jr) +- Update tar-fs to 3.1.1 via pnpm override (PR #9450 by @app/roomote) + +## [3.33.2] - 2025-11-19 + +- Enable native tool calling for Gemini provider (PR #9343 by @hannesrudolph) +- Add RCC credit balance display (PR #9386 by @jr) +- Fix: Preserve user images in native tool call results (PR #9401 by @daniel-lxs) +- Perf: Reduce excessive getModel() calls and implement disk cache fallback (PR #9410 by @daniel-lxs) +- Show zero price for free models (PR #9419 by @mrubens) + +## [3.33.1] - 2025-11-18 + +![3.33.1 Release - Native Tool Protocol Fixes](/releases/3.33.1-release.png) + +- Add native tool calling support to OpenAI-compatible (PR #9369 by @mrubens) +- Fix: Resolve native tool protocol race condition causing 400 errors (PR #9363 by @daniel-lxs) +- Fix: Update tools to return structured JSON for native protocol (PR #9373 by @daniel-lxs) +- Fix: Include nativeArgs in tool repetition detection (PR #9377 by @daniel-lxs) +- Fix: Ensure no XML parsing when protocol is native (PR #9371 by @daniel-lxs) +- Fix: Gemini maxOutputTokens and reasoning config (PR #9375 by @hannesrudolph) +- Fix: Gemini thought signature validation and token counting errors (PR #9380 by @hannesrudolph) +- Fix: Exclude XML tool examples from MODES section when native protocol enabled (PR #9367 by @daniel-lxs) +- Retry eval tasks if API instability detected (PR #9365 by @cte) +- Add toolProtocol property to PostHog tool usage telemetry (PR #9374 by @app/roomote) + +## [3.33.0] - 2025-11-18 + +![3.33.0 Release - Twin Kangaroos and the Gemini Constellation](/releases/3.33.0-release.png) + +- Add Gemini 3 Pro Preview model (PR #9357 by @hannesrudolph) +- Improve Google Gemini defaults with better temperature and cost reporting (PR #9327 by @hannesrudolph) +- Enable native tool calling for openai-native provider (PR #9348 by @hannesrudolph) +- Add git status information to environment details (PR #9310 by @daniel-lxs) +- Add tool protocol selector to advanced settings (PR #9324 by @daniel-lxs) +- Implement dynamic tool protocol resolution with proper precedence hierarchy (PR #9286 by @daniel-lxs) +- Move Import/Export functionality to Modes view toolbar and cleanup Mode Edit view (PR #9077 by @hannesrudolph) +- Update cloud agent CTA to point to setup page (PR #9338 by @app/roomote) +- Fix: Prevent duplicate tool_result blocks in native tool protocol (PR #9248 by @daniel-lxs) +- Fix: Format tool responses properly for native protocol (PR #9270 by @daniel-lxs) +- Fix: Centralize toolProtocol configuration checks (PR #9279 by @daniel-lxs) +- Fix: Preserve tool blocks for native protocol in conversation history (PR #9319 by @daniel-lxs) +- Fix: Prevent infinite loop when task_done succeeds (PR #9325 by @daniel-lxs) +- Fix: Sync parser state with profile/model changes (PR #9355 by @daniel-lxs) +- Fix: Pass tool protocol parameter to lineCountTruncationError (PR #9358 by @daniel-lxs) +- Use VSCode theme color for outline button borders (PR #9336 by @app/roomote) +- Replace broken badgen.net badges with shields.io (PR #9318 by @app/roomote) +- Add max git status files setting to evals (PR #9322 by @mrubens) +- Roo Code Cloud Provider pricing page and changes elsewhere (PR #9195 by @brunobergher) + +## [3.32.1] - 2025-11-14 + +![3.32.1 Release - Bug Fixes](/releases/3.32.1-release.png) + +- Fix: Add abort controller for request cancellation in OpenAI native protocol (PR #9276 by @daniel-lxs) +- Fix: Resolve duplicate tool blocks causing 'tool has already been used' error in native protocol mode (PR #9275 by @daniel-lxs) +- Fix: Prevent duplicate tool_result blocks in native protocol mode for read_file (PR #9272 by @daniel-lxs) +- Fix: Correct OpenAI Native handling of encrypted reasoning blocks to prevent errors during condensing (PR #9263 by @hannesrudolph) +- Fix: Disable XML parser for native tool protocol to prevent parsing conflicts (PR #9277 by @daniel-lxs) + +## [3.32.0] - 2025-11-14 + +![3.32.0 Release - GPT-5.1 models and OpenAI prompt caching](/releases/3.32.0-release.png) + +- Feature: Add GPT-5.1 models to OpenAI provider (PR #9252 by @hannesrudolph) +- Feature: Support for OpenAI Responses 24 hour prompt caching (PR #9259 by @hannesrudolph) +- Fix: Repair the share button in the UI (PR #9253 by @hannesrudolph) +- Docs: Include PR numbers in the release guide to improve traceability (PR #9236 by @hannesrudolph) + +## [3.31.3] - 2025-11-13 + +![3.31.3 Release - Kangaroo Decrypting a Message](/releases/3.31.3-release.png) + +- Fix: OpenAI Native encrypted_content handling and remove gpt-5-chat-latest verbosity flag (#9225 by @politsin, PR by @hannesrudolph) +- Fix: Roo Code Cloud provider Anthropic input token normalization to avoid double-counting (thanks @hannesrudolph!) +- Refactor: Rename sliding-window to context-management and truncateConversationIfNeeded to manageContext (thanks @hannesrudolph!) + +## [3.31.2] - 2025-11-12 + +- Fix: Apply updated API profile settings when provider/model unchanged (#9208 by @hannesrudolph, PR by @hannesrudolph) +- Migrate conversation continuity to plugin-side encrypted reasoning items using Responses API for improved reliability (thanks @hannesrudolph!) +- Fix: Include mcpServers in getState() for auto-approval (#9190 by @bozoweed, PR by @daniel-lxs) +- Batch settings updates from the webview to the extension host for improved performance (thanks @cte!) +- Fix: Replace rate-limited badges with badgen.net to improve README reliability (thanks @daniel-lxs!) + ## [3.31.1] - 2025-11-11 ![3.31.1 Release - Kangaroo Stuck in the Clouds](/releases/3.31.1-release.png) @@ -206,7 +552,7 @@ ## [3.28.11] - 2025-09-29 -- Fix: Correct AWS Bedrock Claude Sonnet 4.5 model identifier (#8371 by @sunhyung, PR by @app/roomote) +- Fix: Correct Amazon Bedrock Claude Sonnet 4.5 model identifier (#8371 by @sunhyung, PR by @app/roomote) - Fix: Correct Claude Sonnet 4.5 model ID format (thanks @daniel-lxs!) ## [3.28.10] - 2025-09-29 @@ -538,7 +884,7 @@ ## [3.25.14] - 2025-08-13 - Fix: Only include verbosity parameter for models that support it (#7054 by @eastonmeth, PR by @app/roomote) -- Fix: AWS Bedrock 1M context - Move anthropic_beta to additionalModelRequestFields (thanks @daniel-lxs!) +- Fix: Amazon Bedrock 1M context - Move anthropic_beta to additionalModelRequestFields (thanks @daniel-lxs!) - Fix: Make cancelling requests more responsive by reverting recent changes ## [3.25.13] - 2025-08-12 @@ -903,7 +1249,7 @@ - Add user-configurable search score threshold slider for semantic search (thanks @hannesrudolph!) - Add default headers and testing for litellm fetcher (thanks @andrewshu2000!) - Fix consistent cancellation error messages for thinking vs streaming phases -- Fix AWS Bedrock cross-region inference profile mapping (thanks @KevinZhao!) +- Fix Amazon Bedrock cross-region inference profile mapping (thanks @KevinZhao!) - Fix URL loading timeout issues in @ mentions (thanks @MuriloFP!) - Fix API retry exponential backoff capped at 10 minutes (thanks @MuriloFP!) - Fix Qdrant URL field auto-filling with default value (thanks @SannidhyaSah!) @@ -917,7 +1263,7 @@ - Suppress Mermaid error rendering - Improve Mermaid buttons with light background in light mode (thanks @chrarnoldus!) - Add .vscode/ to write-protected files/directories -- Update AWS Bedrock cross-region inference profile mapping (thanks @KevinZhao!) +- Update Amazon Bedrock cross-region inference profile mapping (thanks @KevinZhao!) ## [3.22.5] - 2025-06-28 @@ -1541,7 +1887,7 @@ - Improved display of diff errors + easy copying for investigation - Fixes to .vscodeignore (thanks @franekp!) - Fix a zh-CN translation for model capabilities (thanks @zhangtony239!) -- Rename AWS Bedrock to Amazon Bedrock (thanks @ronyblum!) +- Rename Amazon Bedrock to Amazon Bedrock (thanks @ronyblum!) - Update extension title and description (thanks @StevenTCramer!) ## [3.11.12] - 2025-04-09 @@ -1790,12 +2136,12 @@ - PowerShell-specific command handling (thanks @KJ7LNW!) - OpenAI-compatible DeepSeek/QwQ reasoning support (thanks @lightrabbit!) - Anthropic-style prompt caching in the OpenAI-compatible provider (thanks @dleen!) -- Add Deepseek R1 for AWS Bedrock (thanks @ATempsch!) +- Add Deepseek R1 for Amazon Bedrock (thanks @ATempsch!) - Fix MarkdownBlock text color for Dark High Contrast theme (thanks @cannuri!) - Add gemini-2.0-pro-exp-02-05 model to vertex (thanks @shohei-ihaya!) - Bring back progress status for multi-diff edits (thanks @qdaxb!) - Refactor alert dialog styles to use the correct vscode theme (thanks @cannuri!) -- Custom ARNs in AWS Bedrock (thanks @Smartsheet-JB-Brown!) +- Custom ARNs in Amazon Bedrock (thanks @Smartsheet-JB-Brown!) - Update MCP servers directory path for platform compatibility (thanks @hannesrudolph!) - Fix browser system prompt inclusion rules (thanks @cannuri!) - Publish git tags to github from CI (thanks @pdecat!) @@ -1933,7 +2279,7 @@ ## [3.7.1] - 2025-02-24 -- Add AWS Bedrock support for Sonnet 3.7 and update some defaults to Sonnet 3.7 instead of 3.5 +- Add Amazon Bedrock support for Sonnet 3.7 and update some defaults to Sonnet 3.7 instead of 3.5 ## [3.7.0] - 2025-02-24 @@ -1950,7 +2296,7 @@ ## [3.3.24] - 2025-02-20 -- Fixed a bug with region selection preventing AWS Bedrock profiles from being saved (thanks @oprstchn!) +- Fixed a bug with region selection preventing Amazon Bedrock profiles from being saved (thanks @oprstchn!) - Updated the price of gpt-4o (thanks @marvijo-code!) ## [3.3.23] - 2025-02-20 @@ -2134,7 +2480,7 @@ - Reverts provider key entry back to checking onInput instead of onChange to hopefully address issues entering API keys (thanks @samhvw8!) - Added explicit checkbox to use Azure for OpenAI compatible providers (thanks @samhvw8!) - Fixed Glama usage reporting (thanks @punkpeye!) -- Added Llama 3.3 70B Instruct model to the AWS Bedrock provider options (thanks @Premshay!) +- Added Llama 3.3 70B Instruct model to the Amazon Bedrock provider options (thanks @Premshay!) ## [3.2.7] diff --git a/README.md b/README.md index d6a7d99c8e..9e98cd8845 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@

- VS Code - Installs - Rating + VS Code Marketplace X YouTube Join Discord @@ -37,7 +35,7 @@ - [简体中文](locales/zh-CN/README.md) - [繁體中文](locales/zh-TW/README.md) - ... - + --- @@ -169,16 +167,6 @@ We love community contributions! Get started by reading our [CONTRIBUTING.md](CO --- -## Contributors - -Thanks to all our contributors who have helped make Roo Code better! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## License [Apache 2.0 © 2025 Roo Code, Inc.](./LICENSE) diff --git a/apps/vscode-e2e/package.json b/apps/vscode-e2e/package.json index 1d19ffebf2..d366f72a2d 100644 --- a/apps/vscode-e2e/package.json +++ b/apps/vscode-e2e/package.json @@ -18,7 +18,7 @@ "@types/vscode": "^1.95.0", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.4.0", - "glob": "^11.0.1", + "glob": "^11.1.0", "mocha": "^11.1.0", "rimraf": "^6.0.1", "typescript": "5.8.3" diff --git a/apps/vscode-e2e/src/suite/extension.test.ts b/apps/vscode-e2e/src/suite/extension.test.ts index e7a92521cf..5d59e003ef 100644 --- a/apps/vscode-e2e/src/suite/extension.test.ts +++ b/apps/vscode-e2e/src/suite/extension.test.ts @@ -15,8 +15,6 @@ suite("Roo Code Extension", function () { "SidebarProvider.removeView", "activationCompleted", "plusButtonClicked", - "mcpButtonClicked", - "promptsButtonClicked", "popoutButtonClicked", "openInNewTab", "settingsButtonClicked", diff --git a/apps/vscode-e2e/src/suite/tools/insert-content.test.ts b/apps/vscode-e2e/src/suite/tools/insert-content.test.ts deleted file mode 100644 index a3a3abb186..0000000000 --- a/apps/vscode-e2e/src/suite/tools/insert-content.test.ts +++ /dev/null @@ -1,628 +0,0 @@ -import * as assert from "assert" -import * as fs from "fs/promises" -import * as path from "path" -import * as vscode from "vscode" - -import { RooCodeEventName, type ClineMessage } from "@roo-code/types" - -import { waitFor, sleep } from "../utils" -import { setDefaultSuiteTimeout } from "../test-utils" - -suite.skip("Roo Code insert_content Tool", function () { - setDefaultSuiteTimeout(this) - - let workspaceDir: string - - // Pre-created test files that will be used across tests - const testFiles = { - simpleText: { - name: `test-insert-simple-${Date.now()}.txt`, - content: "Line 1\nLine 2\nLine 3", - path: "", - }, - jsFile: { - name: `test-insert-js-${Date.now()}.js`, - content: `function hello() { - console.log("Hello World") -} - -function goodbye() { - console.log("Goodbye World") -}`, - path: "", - }, - emptyFile: { - name: `test-insert-empty-${Date.now()}.txt`, - content: "", - path: "", - }, - pythonFile: { - name: `test-insert-python-${Date.now()}.py`, - content: `def main(): - print("Start") - print("End")`, - path: "", - }, - } - - // Get the actual workspace directory that VSCode is using and create all test files - suiteSetup(async function () { - // Get the workspace folder from VSCode - const workspaceFolders = vscode.workspace.workspaceFolders - if (!workspaceFolders || workspaceFolders.length === 0) { - throw new Error("No workspace folder found") - } - workspaceDir = workspaceFolders[0]!.uri.fsPath - console.log("Using workspace directory:", workspaceDir) - - // Create all test files before any tests run - console.log("Creating test files in workspace...") - for (const [key, file] of Object.entries(testFiles)) { - file.path = path.join(workspaceDir, file.name) - await fs.writeFile(file.path, file.content) - console.log(`Created ${key} test file at:`, file.path) - } - - // Verify all files exist - for (const [key, file] of Object.entries(testFiles)) { - const exists = await fs - .access(file.path) - .then(() => true) - .catch(() => false) - if (!exists) { - throw new Error(`Failed to create ${key} test file at ${file.path}`) - } - } - }) - - // Clean up after all tests - suiteTeardown(async () => { - // Cancel any running tasks before cleanup - test("Should insert content at the beginning of a file (line 1)", async function () { - const api = globalThis.api - // Clean up before each test - setup(async () => { - // Cancel any previous task - try { - await globalThis.api.cancelCurrentTask() - } catch { - // Task might not be running - } - - // Small delay to ensure clean state - await sleep(100) - }) - - // Clean up after each test - teardown(async () => { - // Cancel the current task - try { - await globalThis.api.cancelCurrentTask() - } catch { - // Task might not be running - } - - // Small delay to ensure clean state - await sleep(100) - }) - const messages: ClineMessage[] = [] - const testFile = testFiles.simpleText - const insertContent = "New first line" - const expectedContent = `${insertContent} -${testFile.content}` - let taskStarted = false - let taskCompleted = false - let errorOccurred: string | null = null - let insertContentExecuted = false - - // Listen for messages - const messageHandler = ({ message }: { message: ClineMessage }) => { - messages.push(message) - - // Log important messages for debugging - if (message.type === "say" && message.say === "error") { - errorOccurred = message.text || "Unknown error" - console.error("Error:", message.text) - } - if (message.type === "ask" && message.ask === "tool") { - console.log("Tool request:", message.text?.substring(0, 200)) - } - if (message.type === "say" && (message.say === "completion_result" || message.say === "text")) { - console.log("AI response:", message.text?.substring(0, 200)) - } - - // Check for tool execution - if (message.type === "say" && message.say === "api_req_started" && message.text) { - console.log("API request started:", message.text.substring(0, 200)) - try { - const requestData = JSON.parse(message.text) - if (requestData.request && requestData.request.includes("insert_content")) { - insertContentExecuted = true - console.log("insert_content tool executed!") - } - } catch (e) { - console.log("Failed to parse api_req_started message:", e) - } - } - } - api.on(RooCodeEventName.Message, messageHandler) - - // Listen for task events - const taskStartedHandler = (id: string) => { - if (id === taskId) { - taskStarted = true - console.log("Task started:", id) - } - } - api.on(RooCodeEventName.TaskStarted, taskStartedHandler) - - const taskCompletedHandler = (id: string) => { - if (id === taskId) { - taskCompleted = true - console.log("Task completed:", id) - } - } - api.on(RooCodeEventName.TaskCompleted, taskCompletedHandler) - - let taskId: string - try { - // Start the task - taskId = await api.startNewTask({ - configuration: { - mode: "code", - autoApprovalEnabled: true, - alwaysAllowWrite: true, - alwaysAllowReadOnly: true, - alwaysAllowReadOnlyOutsideWorkspace: true, - }, - text: `Use insert_content to add "${insertContent}" at line 1 (beginning) of the file ${testFile.name}. The file already exists with this content: -${testFile.content} - -Assume the file exists and you can modify it directly.`, - }) - - console.log("Task ID:", taskId) - console.log("Test filename:", testFile.name) - - // Wait for task to start - await waitFor(() => taskStarted, { timeout: 45_000 }) - - // Check for early errors - if (errorOccurred) { - console.error("Early error detected:", errorOccurred) - } - - // Wait for task completion - await waitFor(() => taskCompleted, { timeout: 45_000 }) - - // Give extra time for file system operations - await sleep(2000) - - // Check if the file was modified correctly - const actualContent = await fs.readFile(testFile.path, "utf-8") - console.log("File content after insertion:", actualContent) - - // Verify tool was executed - assert.strictEqual(insertContentExecuted, true, "insert_content tool should have been executed") - - // Verify file content - assert.strictEqual( - actualContent.trim(), - expectedContent.trim(), - "Content should be inserted at the beginning of the file", - ) - - // Verify no errors occurred - assert.strictEqual( - errorOccurred, - null, - `Task should complete without errors, but got: ${errorOccurred}`, - ) - - console.log("Test passed! insert_content tool executed and content inserted at beginning successfully") - } finally { - api.off(RooCodeEventName.Message, messageHandler) - api.off(RooCodeEventName.TaskStarted, taskStartedHandler) - api.off(RooCodeEventName.TaskCompleted, taskCompletedHandler) - } - }) - try { - await globalThis.api.cancelCurrentTask() - } catch { - // Task might not be running - } - - // Clean up all test files - console.log("Cleaning up test files...") - for (const [key, file] of Object.entries(testFiles)) { - try { - await fs.unlink(file.path) - console.log(`Cleaned up ${key} test file`) - } catch (error) { - console.log(`Failed to clean up ${key} test file:`, error) - } - } - }) - - test("Should insert content at the end of a file (line 0)", async function () { - const api = globalThis.api - const messages: ClineMessage[] = [] - const testFile = testFiles.simpleText - const insertContent = "New last line" - const expectedContent = `${testFile.content} -${insertContent}` - let taskStarted = false - let taskCompleted = false - let errorOccurred: string | null = null - let insertContentExecuted = false - - // Listen for messages - const messageHandler = ({ message }: { message: ClineMessage }) => { - messages.push(message) - - // Log important messages for debugging - if (message.type === "say" && message.say === "error") { - errorOccurred = message.text || "Unknown error" - console.error("Error:", message.text) - } - if (message.type === "ask" && message.ask === "tool") { - console.log("Tool request:", message.text?.substring(0, 200)) - } - if (message.type === "say" && (message.say === "completion_result" || message.say === "text")) { - console.log("AI response:", message.text?.substring(0, 200)) - } - - // Check for tool execution - if (message.type === "say" && message.say === "api_req_started" && message.text) { - console.log("API request started:", message.text.substring(0, 200)) - try { - const requestData = JSON.parse(message.text) - if (requestData.request && requestData.request.includes("insert_content")) { - insertContentExecuted = true - console.log("insert_content tool executed!") - } - } catch (e) { - console.log("Failed to parse api_req_started message:", e) - } - } - } - api.on(RooCodeEventName.Message, messageHandler) - - // Listen for task events - const taskStartedHandler = (id: string) => { - if (id === taskId) { - taskStarted = true - console.log("Task started:", id) - } - } - api.on(RooCodeEventName.TaskStarted, taskStartedHandler) - - const taskCompletedHandler = (id: string) => { - if (id === taskId) { - taskCompleted = true - console.log("Task completed:", id) - } - } - api.on(RooCodeEventName.TaskCompleted, taskCompletedHandler) - - let taskId: string - try { - // Start the task - taskId = await api.startNewTask({ - configuration: { - mode: "code", - autoApprovalEnabled: true, - alwaysAllowWrite: true, - alwaysAllowReadOnly: true, - alwaysAllowReadOnlyOutsideWorkspace: true, - }, - text: `Use insert_content to add "${insertContent}" at line 0 (end of file) of the file ${testFile.name}. The file already exists with this content: -${testFile.content} - -Assume the file exists and you can modify it directly.`, - }) - - console.log("Task ID:", taskId) - console.log("Test filename:", testFile.name) - - // Wait for task to start - await waitFor(() => taskStarted, { timeout: 45_000 }) - - // Check for early errors - if (errorOccurred) { - console.error("Early error detected:", errorOccurred) - } - - // Wait for task completion - await waitFor(() => taskCompleted, { timeout: 45_000 }) - - // Give extra time for file system operations - await sleep(2000) - - // Check if the file was modified correctly - const actualContent = await fs.readFile(testFile.path, "utf-8") - console.log("File content after insertion:", actualContent) - - // Verify tool was executed - test("Should insert multiline content into a JavaScript file", async function () { - const api = globalThis.api - const messages: ClineMessage[] = [] - const testFile = testFiles.jsFile - const insertContent = `// New import statements -import { utils } from './utils' -import { helpers } from './helpers'` - const expectedContent = `${insertContent} -${testFile.content}` - let taskStarted = false - let taskCompleted = false - let errorOccurred: string | null = null - let insertContentExecuted = false - - // Listen for messages - const messageHandler = ({ message }: { message: ClineMessage }) => { - messages.push(message) - - // Log important messages for debugging - if (message.type === "say" && message.say === "error") { - errorOccurred = message.text || "Unknown error" - console.error("Error:", message.text) - } - if (message.type === "ask" && message.ask === "tool") { - console.log("Tool request:", message.text?.substring(0, 200)) - } - if (message.type === "say" && (message.say === "completion_result" || message.say === "text")) { - console.log("AI response:", message.text?.substring(0, 200)) - } - - // Check for tool execution - if (message.type === "say" && message.say === "api_req_started" && message.text) { - console.log("API request started:", message.text.substring(0, 200)) - try { - const requestData = JSON.parse(message.text) - if (requestData.request && requestData.request.includes("insert_content")) { - insertContentExecuted = true - console.log("insert_content tool executed!") - } - } catch (e) { - console.log("Failed to parse api_req_started message:", e) - } - } - } - api.on(RooCodeEventName.Message, messageHandler) - - // Listen for task events - const taskStartedHandler = (id: string) => { - if (id === taskId) { - taskStarted = true - console.log("Task started:", id) - } - } - api.on(RooCodeEventName.TaskStarted, taskStartedHandler) - - const taskCompletedHandler = (id: string) => { - if (id === taskId) { - taskCompleted = true - console.log("Task completed:", id) - } - } - api.on(RooCodeEventName.TaskCompleted, taskCompletedHandler) - - let taskId: string - try { - // Start the task - taskId = await api.startNewTask({ - configuration: { - mode: "code", - autoApprovalEnabled: true, - alwaysAllowWrite: true, - alwaysAllowReadOnly: true, - alwaysAllowReadOnlyOutsideWorkspace: true, - }, - text: `Use insert_content to add import statements at the beginning (line 1) of the JavaScript file ${testFile.name}. Add these lines: -${insertContent} - -The file already exists with this content: -${testFile.content} - -Assume the file exists and you can modify it directly.`, - }) - - console.log("Task ID:", taskId) - console.log("Test filename:", testFile.name) - - // Wait for task to start - await waitFor(() => taskStarted, { timeout: 45_000 }) - - // Check for early errors - if (errorOccurred) { - console.error("Early error detected:", errorOccurred) - } - - // Wait for task completion - await waitFor(() => taskCompleted, { timeout: 45_000 }) - - // Give extra time for file system operations - await sleep(2000) - - test("Should insert content into an empty file", async function () { - const api = globalThis.api - const messages: ClineMessage[] = [] - const testFile = testFiles.emptyFile - const insertContent = `# My New File -This is the first line of content -And this is the second line` - const expectedContent = insertContent - let taskStarted = false - let taskCompleted = false - let errorOccurred: string | null = null - let insertContentExecuted = false - - // Listen for messages - const messageHandler = ({ message }: { message: ClineMessage }) => { - messages.push(message) - - // Log important messages for debugging - if (message.type === "say" && message.say === "error") { - errorOccurred = message.text || "Unknown error" - console.error("Error:", message.text) - } - if (message.type === "ask" && message.ask === "tool") { - console.log("Tool request:", message.text?.substring(0, 200)) - } - if ( - message.type === "say" && - (message.say === "completion_result" || message.say === "text") - ) { - console.log("AI response:", message.text?.substring(0, 200)) - } - - // Check for tool execution - if (message.type === "say" && message.say === "api_req_started" && message.text) { - console.log("API request started:", message.text.substring(0, 200)) - try { - const requestData = JSON.parse(message.text) - if (requestData.request && requestData.request.includes("insert_content")) { - insertContentExecuted = true - console.log("insert_content tool executed!") - } - } catch (e) { - console.log("Failed to parse api_req_started message:", e) - } - } - } - api.on(RooCodeEventName.Message, messageHandler) - - // Listen for task events - const taskStartedHandler = (id: string) => { - if (id === taskId) { - taskStarted = true - console.log("Task started:", id) - } - } - api.on(RooCodeEventName.TaskStarted, taskStartedHandler) - - const taskCompletedHandler = (id: string) => { - if (id === taskId) { - taskCompleted = true - console.log("Task completed:", id) - } - } - api.on(RooCodeEventName.TaskCompleted, taskCompletedHandler) - - let taskId: string - try { - // Start the task - taskId = await api.startNewTask({ - configuration: { - mode: "code", - autoApprovalEnabled: true, - alwaysAllowWrite: true, - alwaysAllowReadOnly: true, - alwaysAllowReadOnlyOutsideWorkspace: true, - }, - text: `Use insert_content to add content to the empty file ${testFile.name}. Add this content at line 0 (end of file): -${insertContent} - -The file is currently empty. Assume the file exists and you can modify it directly.`, - }) - - console.log("Task ID:", taskId) - console.log("Test filename:", testFile.name) - - // Wait for task to start - await waitFor(() => taskStarted, { timeout: 45_000 }) - - // Check for early errors - if (errorOccurred) { - console.error("Early error detected:", errorOccurred) - } - - // Wait for task completion - await waitFor(() => taskCompleted, { timeout: 45_000 }) - - // Give extra time for file system operations - await sleep(2000) - - // Check if the file was modified correctly - const actualContent = await fs.readFile(testFile.path, "utf-8") - console.log("File content after insertion:", actualContent) - - // Verify tool was executed - assert.strictEqual( - insertContentExecuted, - true, - "insert_content tool should have been executed", - ) - - // Verify file content - assert.strictEqual( - actualContent.trim(), - expectedContent.trim(), - "Content should be inserted into the empty file", - ) - - // Verify no errors occurred - assert.strictEqual( - errorOccurred, - null, - `Task should complete without errors, but got: ${errorOccurred}`, - ) - - console.log( - "Test passed! insert_content tool executed and content inserted into empty file successfully", - ) - } finally { - api.off(RooCodeEventName.Message, messageHandler) - api.off(RooCodeEventName.TaskStarted, taskStartedHandler) - api.off(RooCodeEventName.TaskCompleted, taskCompletedHandler) - } - }) - // Check if the file was modified correctly - const actualContent = await fs.readFile(testFile.path, "utf-8") - console.log("File content after insertion:", actualContent) - - // Verify tool was executed - assert.strictEqual(insertContentExecuted, true, "insert_content tool should have been executed") - - // Verify file content - assert.strictEqual( - actualContent.trim(), - expectedContent.trim(), - "Multiline content should be inserted at the beginning of the JavaScript file", - ) - - // Verify no errors occurred - assert.strictEqual( - errorOccurred, - null, - `Task should complete without errors, but got: ${errorOccurred}`, - ) - - console.log("Test passed! insert_content tool executed and multiline content inserted successfully") - } finally { - api.off(RooCodeEventName.Message, messageHandler) - api.off(RooCodeEventName.TaskStarted, taskStartedHandler) - api.off(RooCodeEventName.TaskCompleted, taskCompletedHandler) - } - }) - assert.strictEqual(insertContentExecuted, true, "insert_content tool should have been executed") - - // Verify file content - assert.strictEqual( - actualContent.trim(), - expectedContent.trim(), - "Content should be inserted at the end of the file", - ) - - // Verify no errors occurred - assert.strictEqual(errorOccurred, null, `Task should complete without errors, but got: ${errorOccurred}`) - - console.log("Test passed! insert_content tool executed and content inserted at end successfully") - } finally { - api.off(RooCodeEventName.Message, messageHandler) - api.off(RooCodeEventName.TaskStarted, taskStartedHandler) - api.off(RooCodeEventName.TaskCompleted, taskCompletedHandler) - } - }) - // Tests will be added here one by one -}) diff --git a/apps/web-evals/package.json b/apps/web-evals/package.json index 3774016332..b2ac0d4346 100644 --- a/apps/web-evals/package.json +++ b/apps/web-evals/package.json @@ -14,6 +14,7 @@ "dependencies": { "@hookform/resolvers": "^5.1.1", "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-checkbox": "^1.1.5", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-label": "^2.1.2", @@ -28,12 +29,13 @@ "@roo-code/evals": "workspace:^", "@roo-code/types": "workspace:^", "@tanstack/react-query": "^5.69.0", + "archiver": "^7.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.0", "fuzzysort": "^3.1.0", "lucide-react": "^0.518.0", - "next": "^15.2.5", + "next": "~15.2.6", "next-themes": "^0.4.6", "p-map": "^7.0.3", "react": "^18.3.1", @@ -51,6 +53,7 @@ "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", "@tailwindcss/postcss": "^4", + "@types/archiver": "^7.0.0", "@types/ps-tree": "^1.1.6", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.5", diff --git a/apps/web-evals/src/actions/__tests__/killRun.spec.ts b/apps/web-evals/src/actions/__tests__/killRun.spec.ts new file mode 100644 index 0000000000..814d70d9fc --- /dev/null +++ b/apps/web-evals/src/actions/__tests__/killRun.spec.ts @@ -0,0 +1,207 @@ +// npx vitest run src/actions/__tests__/killRun.spec.ts + +import { execFileSync } from "child_process" + +// Mock child_process +vi.mock("child_process", () => ({ + execFileSync: vi.fn(), + spawn: vi.fn(), +})) + +// Mock next/cache +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})) + +// Mock redis client +vi.mock("@/lib/server/redis", () => ({ + redisClient: vi.fn().mockResolvedValue({ + del: vi.fn().mockResolvedValue(1), + }), +})) + +// Mock @roo-code/evals +vi.mock("@roo-code/evals", () => ({ + createRun: vi.fn(), + deleteRun: vi.fn(), + createTask: vi.fn(), + exerciseLanguages: [], + getExercisesForLanguage: vi.fn().mockResolvedValue([]), +})) + +// Mock timers to speed up tests +vi.useFakeTimers() + +// Import after mocks +import { killRun } from "../runs" + +const mockExecFileSync = execFileSync as ReturnType + +describe("killRun", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllTimers() + }) + + it("should kill controller first, wait, then kill task containers", async () => { + const runId = 123 + + // execFileSync is used for all docker commands + mockExecFileSync + .mockReturnValueOnce("") // docker kill controller + .mockReturnValueOnce("evals-task-123-456.0\nevals-task-123-789.1\n") // docker ps + .mockReturnValueOnce("") // docker kill evals-task-123-456.0 + .mockReturnValueOnce("") // docker kill evals-task-123-789.1 + + const resultPromise = killRun(runId) + + // Fast-forward past the 10 second sleep + await vi.advanceTimersByTimeAsync(10000) + + const result = await resultPromise + + expect(result.success).toBe(true) + expect(result.killedContainers).toContain("evals-controller-123") + expect(result.killedContainers).toContain("evals-task-123-456.0") + expect(result.killedContainers).toContain("evals-task-123-789.1") + expect(result.errors).toHaveLength(0) + + // Verify execFileSync was called for docker kill + expect(mockExecFileSync).toHaveBeenNthCalledWith( + 1, + "docker", + ["kill", "evals-controller-123"], + expect.any(Object), + ) + // Verify execFileSync was called for docker ps with run-specific filter + expect(mockExecFileSync).toHaveBeenNthCalledWith( + 2, + "docker", + ["ps", "--format", "{{.Names}}", "--filter", "name=evals-task-123-"], + expect.any(Object), + ) + }) + + it("should continue killing runners even if controller is not running", async () => { + const runId = 456 + + mockExecFileSync + .mockImplementationOnce(() => { + throw new Error("No such container") + }) // controller kill fails + .mockReturnValueOnce("evals-task-456-100.0\n") // docker ps + .mockReturnValueOnce("") // docker kill task + + const resultPromise = killRun(runId) + await vi.advanceTimersByTimeAsync(10000) + const result = await resultPromise + + expect(result.success).toBe(true) + expect(result.killedContainers).toContain("evals-task-456-100.0") + // Controller not in list since it failed + expect(result.killedContainers).not.toContain("evals-controller-456") + }) + + it("should clear Redis state after killing containers", async () => { + const runId = 789 + + const mockDel = vi.fn().mockResolvedValue(1) + const { redisClient } = await import("@/lib/server/redis") + vi.mocked(redisClient).mockResolvedValue({ del: mockDel } as never) + + mockExecFileSync + .mockReturnValueOnce("") // controller kill + .mockReturnValueOnce("") // docker ps (no tasks) + + const resultPromise = killRun(runId) + await vi.advanceTimersByTimeAsync(10000) + await resultPromise + + expect(mockDel).toHaveBeenCalledWith("heartbeat:789") + expect(mockDel).toHaveBeenCalledWith("runners:789") + }) + + it("should handle docker ps failure gracefully", async () => { + const runId = 111 + + mockExecFileSync + .mockReturnValueOnce("") // controller kill succeeds + .mockImplementationOnce(() => { + throw new Error("Docker error") + }) // docker ps fails + + const resultPromise = killRun(runId) + await vi.advanceTimersByTimeAsync(10000) + const result = await resultPromise + + // Should still be successful because controller was killed + expect(result.success).toBe(true) + expect(result.killedContainers).toContain("evals-controller-111") + expect(result.errors).toContain("Failed to list Docker task containers") + }) + + it("should handle individual task kill failures", async () => { + const runId = 222 + + mockExecFileSync + .mockReturnValueOnce("") // controller kill + .mockReturnValueOnce("evals-task-222-300.0\nevals-task-222-400.0\n") // docker ps + .mockImplementationOnce(() => { + throw new Error("Kill failed") + }) // first task kill fails + .mockReturnValueOnce("") // second task kill succeeds + + const resultPromise = killRun(runId) + await vi.advanceTimersByTimeAsync(10000) + const result = await resultPromise + + expect(result.success).toBe(true) + expect(result.killedContainers).toContain("evals-controller-222") + expect(result.killedContainers).toContain("evals-task-222-400.0") + expect(result.errors.length).toBe(1) + expect(result.errors[0]).toContain("evals-task-222-300.0") + }) + + it("should return success with no containers when nothing is running", async () => { + const runId = 333 + + mockExecFileSync + .mockImplementationOnce(() => { + throw new Error("No such container") + }) // controller not running + .mockReturnValueOnce("") // no task containers + + const resultPromise = killRun(runId) + await vi.advanceTimersByTimeAsync(10000) + const result = await resultPromise + + expect(result.success).toBe(true) + expect(result.killedContainers).toHaveLength(0) + expect(result.errors).toHaveLength(0) + }) + + it("should only kill containers belonging to the specific run", async () => { + const runId = 555 + + mockExecFileSync + .mockReturnValueOnce("") // controller kill + .mockReturnValueOnce("evals-task-555-100.0\n") // docker ps + .mockReturnValueOnce("") // docker kill task + + const resultPromise = killRun(runId) + await vi.advanceTimersByTimeAsync(10000) + const result = await resultPromise + + expect(result.success).toBe(true) + // Verify execFileSync was called for docker ps with run-specific filter + expect(mockExecFileSync).toHaveBeenNthCalledWith( + 2, + "docker", + ["ps", "--format", "{{.Names}}", "--filter", "name=evals-task-555-"], + expect.any(Object), + ) + }) +}) diff --git a/apps/web-evals/src/actions/runs.ts b/apps/web-evals/src/actions/runs.ts index 2eae1f6804..9d213547ce 100644 --- a/apps/web-evals/src/actions/runs.ts +++ b/apps/web-evals/src/actions/runs.ts @@ -3,7 +3,7 @@ import * as path from "path" import fs from "fs" import { fileURLToPath } from "url" -import { spawn } from "child_process" +import { spawn, execFileSync } from "child_process" import { revalidatePath } from "next/cache" import pMap from "p-map" @@ -13,16 +13,22 @@ import { exerciseLanguages, createRun as _createRun, deleteRun as _deleteRun, + updateRun as _updateRun, + getIncompleteRuns as _getIncompleteRuns, + deleteRunsByIds as _deleteRunsByIds, createTask, getExercisesForLanguage, } from "@roo-code/evals" import { CreateRun } from "@/lib/schemas" +import { redisClient } from "@/lib/server/redis" + +// Storage base path for eval logs +const EVALS_STORAGE_PATH = "/tmp/evals/runs" const EVALS_REPO_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../../evals") -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export async function createRun({ suite, exercises = [], systemPrompt, timeout, ...values }: CreateRun) { +export async function createRun({ suite, exercises = [], timeout, iterations = 1, ...values }: CreateRun) { const run = await _createRun({ ...values, timeout, @@ -37,15 +43,34 @@ export async function createRun({ suite, exercises = [], systemPrompt, timeout, throw new Error("Invalid exercise path: " + path) } - await createTask({ ...values, runId: run.id, language: language as ExerciseLanguage, exercise }) + // Create multiple tasks for each iteration + for (let iteration = 1; iteration <= iterations; iteration++) { + await createTask({ + ...values, + runId: run.id, + language: language as ExerciseLanguage, + exercise, + iteration, + }) + } } } else { for (const language of exerciseLanguages) { - const exercises = await getExercisesForLanguage(EVALS_REPO_PATH, language) + const languageExercises = await getExercisesForLanguage(EVALS_REPO_PATH, language) - await pMap(exercises, (exercise) => createTask({ runId: run.id, language, exercise }), { - concurrency: 10, - }) + // Create tasks for all iterations of each exercise + const tasksToCreate: Array<{ language: ExerciseLanguage; exercise: string; iteration: number }> = [] + for (const exercise of languageExercises) { + for (let iteration = 1; iteration <= iterations; iteration++) { + tasksToCreate.push({ language, exercise, iteration }) + } + } + + await pMap( + tasksToCreate, + ({ language, exercise, iteration }) => createTask({ runId: run.id, language, exercise, iteration }), + { concurrency: 10 }, + ) } } @@ -98,3 +123,247 @@ export async function deleteRun(runId: number) { await _deleteRun(runId) revalidatePath("/runs") } + +export type KillRunResult = { + success: boolean + killedContainers: string[] + errors: string[] +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Kill all Docker containers associated with a run (controller and task runners). + * Kills the controller first, waits 10 seconds, then kills runners. + * Also clears Redis state for heartbeat and runners. + * + * Container naming conventions: + * - Controller: evals-controller-{runId} + * - Task runners: evals-task-{runId}-{taskId}.{attempt} + */ +export async function killRun(runId: number): Promise { + const killedContainers: string[] = [] + const errors: string[] = [] + const controllerPattern = `evals-controller-${runId}` + const taskPattern = `evals-task-${runId}-` + + try { + // Step 1: Kill the controller first + console.log(`Killing controller: ${controllerPattern}`) + try { + execFileSync("docker", ["kill", controllerPattern], { encoding: "utf-8", timeout: 10000 }) + killedContainers.push(controllerPattern) + console.log(`Killed controller container: ${controllerPattern}`) + } catch (_error) { + // Controller might not be running - that's ok, continue to kill runners + console.log(`Controller ${controllerPattern} not running or already stopped`) + } + + // Step 2: Wait 10 seconds before killing runners + console.log("Waiting 10 seconds before killing runners...") + await sleep(10000) + + // Step 3: Find and kill all task runner containers for THIS run only + let taskContainerNames: string[] = [] + + try { + const output = execFileSync("docker", ["ps", "--format", "{{.Names}}", "--filter", `name=${taskPattern}`], { + encoding: "utf-8", + timeout: 10000, + }) + taskContainerNames = output + .split("\n") + .map((name) => name.trim()) + .filter((name) => name.length > 0 && name.startsWith(taskPattern)) + } catch (error) { + console.error("Failed to list task containers:", error) + errors.push("Failed to list Docker task containers") + } + + // Kill each task runner container + for (const containerName of taskContainerNames) { + try { + execFileSync("docker", ["kill", containerName], { encoding: "utf-8", timeout: 10000 }) + killedContainers.push(containerName) + console.log(`Killed task container: ${containerName}`) + } catch (error) { + // Container might have already stopped + console.error(`Failed to kill container ${containerName}:`, error) + errors.push(`Failed to kill container: ${containerName}`) + } + } + + // Step 4: Clear Redis state + try { + const redis = await redisClient() + const heartbeatKey = `heartbeat:${runId}` + const runnersKey = `runners:${runId}` + + await redis.del(heartbeatKey) + await redis.del(runnersKey) + console.log(`Cleared Redis keys: ${heartbeatKey}, ${runnersKey}`) + } catch (error) { + console.error("Failed to clear Redis state:", error) + errors.push("Failed to clear Redis state") + } + } catch (error) { + console.error("Error in killRun:", error) + errors.push("Unexpected error while killing containers") + } + + revalidatePath(`/runs/${runId}`) + revalidatePath("/runs") + + return { + success: killedContainers.length > 0 || errors.length === 0, + killedContainers, + errors, + } +} + +export type DeleteIncompleteRunsResult = { + success: boolean + deletedCount: number + deletedRunIds: number[] + storageErrors: string[] +} + +/** + * Delete all incomplete runs (runs without a taskMetricsId/final score). + * Removes both database records and storage folders. + */ +export async function deleteIncompleteRuns(): Promise { + const storageErrors: string[] = [] + + // Get all incomplete runs + const incompleteRuns = await _getIncompleteRuns() + const runIds = incompleteRuns.map((run) => run.id) + + if (runIds.length === 0) { + return { + success: true, + deletedCount: 0, + deletedRunIds: [], + storageErrors: [], + } + } + + // Delete storage folders for each run + for (const runId of runIds) { + const storagePath = path.join(EVALS_STORAGE_PATH, String(runId)) + try { + if (fs.existsSync(storagePath)) { + fs.rmSync(storagePath, { recursive: true, force: true }) + console.log(`Deleted storage folder: ${storagePath}`) + } + } catch (error) { + console.error(`Failed to delete storage folder ${storagePath}:`, error) + storageErrors.push(`Failed to delete storage for run ${runId}`) + } + + // Also try to clear Redis state for any potentially running incomplete runs + try { + const redis = await redisClient() + await redis.del(`heartbeat:${runId}`) + await redis.del(`runners:${runId}`) + } catch (error) { + // Non-critical error, just log it + console.error(`Failed to clear Redis state for run ${runId}:`, error) + } + } + + // Delete from database + await _deleteRunsByIds(runIds) + + revalidatePath("/runs") + + return { + success: true, + deletedCount: runIds.length, + deletedRunIds: runIds, + storageErrors, + } +} + +/** + * Get count of incomplete runs (for UI display) + */ +export async function getIncompleteRunsCount(): Promise { + const incompleteRuns = await _getIncompleteRuns() + return incompleteRuns.length +} + +/** + * Delete all runs older than 30 days. + * Removes both database records and storage folders. + */ +export async function deleteOldRuns(): Promise { + const storageErrors: string[] = [] + + // Get all runs older than 30 days + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + const { getRuns } = await import("@roo-code/evals") + const allRuns = await getRuns() + const oldRuns = allRuns.filter((run) => run.createdAt < thirtyDaysAgo) + const runIds = oldRuns.map((run) => run.id) + + if (runIds.length === 0) { + return { + success: true, + deletedCount: 0, + deletedRunIds: [], + storageErrors: [], + } + } + + // Delete storage folders for each run + for (const runId of runIds) { + const storagePath = path.join(EVALS_STORAGE_PATH, String(runId)) + try { + if (fs.existsSync(storagePath)) { + fs.rmSync(storagePath, { recursive: true, force: true }) + console.log(`Deleted storage folder: ${storagePath}`) + } + } catch (error) { + console.error(`Failed to delete storage folder ${storagePath}:`, error) + storageErrors.push(`Failed to delete storage for run ${runId}`) + } + + // Also try to clear Redis state + try { + const redis = await redisClient() + await redis.del(`heartbeat:${runId}`) + await redis.del(`runners:${runId}`) + } catch (error) { + // Non-critical error, just log it + console.error(`Failed to clear Redis state for run ${runId}:`, error) + } + } + + // Delete from database + await _deleteRunsByIds(runIds) + + revalidatePath("/runs") + + return { + success: true, + deletedCount: runIds.length, + deletedRunIds: runIds, + storageErrors, + } +} + +/** + * Update the description of a run. + */ +export async function updateRunDescription(runId: number, description: string | null): Promise<{ success: boolean }> { + try { + await _updateRun(runId, { description }) + revalidatePath("/runs") + revalidatePath(`/runs/${runId}`) + return { success: true } + } catch (error) { + console.error("Failed to update run description:", error) + return { success: false } + } +} diff --git a/apps/web-evals/src/app/api/health/route.ts b/apps/web-evals/src/app/api/health/route.ts deleted file mode 100644 index ca8a833942..0000000000 --- a/apps/web-evals/src/app/api/health/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse } from "next/server" - -export async function GET() { - try { - return NextResponse.json( - { - status: "healthy", - timestamp: new Date().toISOString(), - uptime: process.uptime(), - environment: process.env.NODE_ENV || "production", - }, - { status: 200 }, - ) - } catch (error) { - return NextResponse.json( - { - status: "unhealthy", - timestamp: new Date().toISOString(), - error: error instanceof Error ? error.message : "Unknown error", - }, - { status: 503 }, - ) - } -} diff --git a/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts b/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts new file mode 100644 index 0000000000..e5ec8751ab --- /dev/null +++ b/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import * as fs from "node:fs/promises" +import * as path from "node:path" + +import { findTask, findRun } from "@roo-code/evals" + +export const dynamic = "force-dynamic" + +const LOG_BASE_PATH = "/tmp/evals/runs" + +// Sanitize path components to prevent path traversal attacks +function sanitizePathComponent(component: string): string { + // Remove any path separators, null bytes, and other dangerous characters + return component.replace(/[/\\:\0*?"<>|]/g, "_") +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string; taskId: string }> }) { + const { id, taskId } = await params + + try { + const runId = Number(id) + const taskIdNum = Number(taskId) + + if (isNaN(runId) || isNaN(taskIdNum)) { + return NextResponse.json({ error: "Invalid run ID or task ID" }, { status: 400 }) + } + + // Verify the run exists + await findRun(runId) + + // Get the task to find its language and exercise + const task = await findTask(taskIdNum) + + // Verify the task belongs to this run + if (task.runId !== runId) { + return NextResponse.json({ error: "Task does not belong to this run" }, { status: 404 }) + } + + // Sanitize language and exercise to prevent path traversal + const safeLanguage = sanitizePathComponent(task.language) + const safeExercise = sanitizePathComponent(task.exercise) + + // Construct the log file path + const logFileName = `${safeLanguage}-${safeExercise}.log` + const logFilePath = path.join(LOG_BASE_PATH, String(runId), logFileName) + + // Verify the resolved path is within the expected directory (defense in depth) + const resolvedPath = path.resolve(logFilePath) + const expectedBase = path.resolve(LOG_BASE_PATH) + if (!resolvedPath.startsWith(expectedBase)) { + return NextResponse.json({ error: "Invalid log path" }, { status: 400 }) + } + + // Check if the log file exists and read it (async) + try { + const logContent = await fs.readFile(logFilePath, "utf-8") + return NextResponse.json({ logContent }) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return NextResponse.json({ error: "Log file not found", logContent: null }, { status: 200 }) + } + throw err + } + } catch (error) { + console.error("Error reading task log:", error) + + if (error instanceof Error && error.name === "RecordNotFoundError") { + return NextResponse.json({ error: "Task or run not found" }, { status: 404 }) + } + + return NextResponse.json({ error: "Failed to read log file" }, { status: 500 }) + } +} diff --git a/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts b/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts new file mode 100644 index 0000000000..f8c6cec06b --- /dev/null +++ b/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts @@ -0,0 +1,129 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import * as fs from "node:fs" +import * as path from "node:path" +import archiver from "archiver" + +import { findRun, getTasks } from "@roo-code/evals" + +export const dynamic = "force-dynamic" + +const LOG_BASE_PATH = "/tmp/evals/runs" + +// Sanitize path components to prevent path traversal attacks +function sanitizePathComponent(component: string): string { + // Remove any path separators, null bytes, and other dangerous characters + return component.replace(/[/\\:\0*?"<>|]/g, "_") +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + + try { + const runId = Number(id) + + if (isNaN(runId)) { + return NextResponse.json({ error: "Invalid run ID" }, { status: 400 }) + } + + // Verify the run exists + await findRun(runId) + + // Get all tasks for this run + const tasks = await getTasks(runId) + + // Filter for failed tasks only + const failedTasks = tasks.filter((task) => task.passed === false) + + if (failedTasks.length === 0) { + return NextResponse.json({ error: "No failed tasks to export" }, { status: 400 }) + } + + // Create a zip archive + const archive = archiver("zip", { zlib: { level: 9 } }) + + // Collect chunks to build the response + const chunks: Buffer[] = [] + + archive.on("data", (chunk: Buffer) => { + chunks.push(chunk) + }) + + // Track archive errors + let archiveError: Error | null = null + archive.on("error", (err: Error) => { + archiveError = err + }) + + // Set up the end promise before finalizing (proper event listener ordering) + const archiveEndPromise = new Promise((resolve, reject) => { + archive.on("end", resolve) + archive.on("error", reject) + }) + + // Add each failed task's log file to the archive + const logDir = path.join(LOG_BASE_PATH, String(runId)) + let filesAdded = 0 + + for (const task of failedTasks) { + // Sanitize language and exercise to prevent path traversal + const safeLanguage = sanitizePathComponent(task.language) + const safeExercise = sanitizePathComponent(task.exercise) + const logFileName = `${safeLanguage}-${safeExercise}.log` + const logFilePath = path.join(logDir, logFileName) + + // Verify the resolved path is within the expected directory (defense in depth) + const resolvedPath = path.resolve(logFilePath) + const expectedBase = path.resolve(LOG_BASE_PATH) + if (!resolvedPath.startsWith(expectedBase)) { + continue // Skip files with suspicious paths + } + + if (fs.existsSync(logFilePath)) { + archive.file(logFilePath, { name: logFileName }) + filesAdded++ + } + } + + // Check if any files were actually added + if (filesAdded === 0) { + archive.abort() + return NextResponse.json( + { error: "No log files found - they may have been cleared from disk" }, + { status: 404 }, + ) + } + + // Finalize the archive + await archive.finalize() + + // Wait for all data to be collected + await archiveEndPromise + + // Check for archive errors + if (archiveError) { + throw archiveError + } + + // Combine all chunks into a single buffer + const zipBuffer = Buffer.concat(chunks) + + // Return the zip file + return new NextResponse(zipBuffer, { + status: 200, + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="run-${runId}-failed-logs.zip"`, + "Content-Length": String(zipBuffer.length), + }, + }) + } catch (error) { + console.error("Error exporting failed logs:", error) + + if (error instanceof Error && error.name === "RecordNotFoundError") { + return NextResponse.json({ error: "Run not found" }, { status: 404 }) + } + + return NextResponse.json({ error: "Failed to export logs" }, { status: 500 }) + } +} diff --git a/apps/web-evals/src/app/runs/[id]/page.tsx b/apps/web-evals/src/app/runs/[id]/page.tsx index aae3fc70f9..8b993eec8a 100644 --- a/apps/web-evals/src/app/runs/[id]/page.tsx +++ b/apps/web-evals/src/app/runs/[id]/page.tsx @@ -7,7 +7,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }> const run = await findRun(Number(id)) return ( -

+
) diff --git a/apps/web-evals/src/app/runs/[id]/run-status.tsx b/apps/web-evals/src/app/runs/[id]/run-status.tsx index 4b94ef14fa..e05b1b51eb 100644 --- a/apps/web-evals/src/app/runs/[id]/run-status.tsx +++ b/apps/web-evals/src/app/runs/[id]/run-status.tsx @@ -1,55 +1,79 @@ "use client" +import { Link2, Link2Off, CheckCircle2 } from "lucide-react" import type { RunStatus as _RunStatus } from "@/hooks/use-run-status" import { cn } from "@/lib/utils" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui" -export const RunStatus = ({ runStatus: { sseStatus, heartbeat, runners = [] } }: { runStatus: _RunStatus }) => ( -
-
-
-
Task Stream:
-
{sseStatus}
-
-
-
-
-
-
-
-
-
Task Controller:
-
{heartbeat ?? "dead"}
-
-
-
-
-
-
-
-
Task Runners:
- {runners.length > 0 &&
{runners?.join(", ")}
} -
-
-) +function StreamIcon({ status }: { status: "connected" | "waiting" | "error" }) { + if (status === "connected") { + return + } + return +} + +export const RunStatus = ({ + runStatus: { sseStatus, heartbeat, runners = [] }, + isComplete = false, +}: { + runStatus: _RunStatus + isComplete?: boolean +}) => { + // For completed runs, show a simple "Complete" badge + if (isComplete) { + return ( + + +
+ +
+
+ + Run complete + +
+ ) + } + + return ( + + +
+ {/* Task Stream status icon */} + + + {/* Task Controller ID */} + {heartbeat ?? "-"} + + {/* Task Runners count */} + 0 ? "text-green-500" : "text-rose-500"}> + {runners.length > 0 ? `${runners.length}r` : "0r"} + +
+
+ +
+
+ + Task Stream: {sseStatus} +
+
+ + Task Controller: {heartbeat ?? "dead"} +
+
+ 0 ? "text-green-500" : "text-rose-500"}>● + Task Runners: {runners.length > 0 ? runners.length : "none"} +
+ {runners.length > 0 && ( +
+ {runners.map((runner) => ( +
{runner}
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/apps/web-evals/src/app/runs/[id]/run.tsx b/apps/web-evals/src/app/runs/[id]/run.tsx index b6c5290b13..41581a21c4 100644 --- a/apps/web-evals/src/app/runs/[id]/run.tsx +++ b/apps/web-evals/src/app/runs/[id]/run.tsx @@ -1,56 +1,642 @@ "use client" -import { useMemo } from "react" -import { LoaderCircle } from "lucide-react" +import { useMemo, useState, useCallback, useEffect } from "react" +import { toast } from "sonner" +import { LoaderCircle, FileText, Copy, Check, StopCircle } from "lucide-react" -import type { Run, TaskMetrics as _TaskMetrics } from "@roo-code/evals" +import type { Run, TaskMetrics as _TaskMetrics, Task } from "@roo-code/evals" +import type { ToolName } from "@roo-code/types" -import { formatCurrency, formatDuration, formatTokens } from "@/lib/formatters" +import { formatCurrency, formatDuration, formatTokens, formatToolUsageSuccessRate } from "@/lib/formatters" import { useRunStatus } from "@/hooks/use-run-status" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui" +import { killRun } from "@/actions/runs" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + ScrollArea, + Button, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui" import { TaskStatus } from "./task-status" import { RunStatus } from "./run-status" type TaskMetrics = Pick<_TaskMetrics, "tokensIn" | "tokensOut" | "tokensContext" | "duration" | "cost"> +type ToolUsageEntry = { attempts: number; failures: number } +type ToolUsage = Record + +// Generate abbreviation from tool name (e.g., "read_file" -> "RF", "list_code_definition_names" -> "LCDN") +function getToolAbbreviation(toolName: string): string { + return toolName + .split("_") + .map((word) => word[0]?.toUpperCase() ?? "") + .join("") +} + +// Pattern definitions for syntax highlighting +type HighlightPattern = { + pattern: RegExp + className: string + // If true, wraps the entire match; if a number, wraps that capture group + wrapGroup?: number +} + +const HIGHLIGHT_PATTERNS: HighlightPattern[] = [ + // Log levels - styled as badges + { pattern: /\|\s*(INFO)\s*\|/g, className: "text-green-400", wrapGroup: 1 }, + { pattern: /\|\s*(WARN|WARNING)\s*\|/g, className: "text-yellow-400", wrapGroup: 1 }, + { pattern: /\|\s*(ERROR)\s*\|/g, className: "text-red-400 font-semibold", wrapGroup: 1 }, + { pattern: /\|\s*(DEBUG)\s*\|/g, className: "text-gray-400", wrapGroup: 1 }, + // Task identifiers - important events + { + pattern: /(taskCreated|taskFocused|taskStarted|taskCompleted|taskAborted|taskResumable)/g, + className: "text-purple-400 font-medium", + }, + // Tool failures - highlight in red + { pattern: /(taskToolFailed)/g, className: "text-red-400 font-bold" }, + { pattern: /(Tool execution failed|tool.*failed|failed.*tool)/gi, className: "text-red-400" }, + { pattern: /(EvalPass)/g, className: "text-green-400 font-bold" }, + { pattern: /(EvalFail)/g, className: "text-red-400 font-bold" }, + // Message arrows + { pattern: /→/g, className: "text-cyan-400" }, + // Tool names in quotes + { pattern: /"(tool)":\s*"([^"]+)"/g, className: "text-orange-400" }, + // JSON keys + { pattern: /"([^"]+)":/g, className: "text-sky-300" }, + // Boolean values + { pattern: /:\s*(true|false)/g, className: "text-amber-400", wrapGroup: 1 }, + // Numbers + { pattern: /:\s*(-?\d+\.?\d*)/g, className: "text-emerald-400", wrapGroup: 1 }, +] + +// Extract timestamp from a log line and return elapsed time from baseline +function formatElapsedTime(timestamp: string, baselineMs: number): string { + const currentMs = new Date(timestamp).getTime() + const elapsedMs = currentMs - baselineMs + const totalSeconds = Math.floor(elapsedMs / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}` +} + +// Extract the first timestamp from the log to use as baseline +function extractFirstTimestamp(log: string): number | null { + // Match timestamp at start of line: [2025-11-28T09:35:23.187Z | ... or [2025-11-28T09:35:23.187Z] + const match = log.match(/\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)[\s|\]]/) + const isoString = match?.[1] + if (!isoString) return null + return new Date(isoString).getTime() +} + +// Simplify log line by removing redundant metadata +function simplifyLogLine(line: string, baselineMs: number | null): { timestamp: string; simplified: string } { + // Extract timestamp - matches [2025-11-28T09:35:23.187Z | ... format + const timestampMatch = line.match(/\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)[\s|\]]/) + const isoTimestamp = timestampMatch?.[1] + if (!isoTimestamp) { + return { timestamp: "", simplified: line } + } + + const timestamp = baselineMs !== null ? formatElapsedTime(isoTimestamp, baselineMs) : isoTimestamp.slice(11, 19) + + // Remove the timestamp from the line (handles both [timestamp] and [timestamp | formats) + let simplified = line.replace(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\s*\|?\s*/, "") + + // Remove redundant metadata: pid, run, task IDs (they're same for entire log) + simplified = simplified.replace(/\|\s*pid:\d+\s*/g, "") + simplified = simplified.replace(/\|\s*run:\d+\s*/g, "") + simplified = simplified.replace(/\|\s*task:\d+\s*/g, "") + simplified = simplified.replace(/runTask\s*\|\s*/g, "") + + // Clean up extra pipes, spaces, and trailing brackets + simplified = simplified.replace(/\|\s*\|/g, "|") + simplified = simplified.replace(/^\s*\|\s*/, "") + simplified = simplified.replace(/\]\s*$/, "") // Remove trailing bracket if present + + return { timestamp, simplified } +} + +// Format a single line with syntax highlighting using React elements (XSS-safe) +function formatLine(line: string): React.ReactNode[] { + // Find all matches with their positions + type Match = { start: number; end: number; text: string; className: string } + const matches: Match[] = [] + + for (const { pattern, className, wrapGroup } of HIGHLIGHT_PATTERNS) { + // Reset regex state + pattern.lastIndex = 0 + let regexMatch + while ((regexMatch = pattern.exec(line)) !== null) { + const capturedText = wrapGroup !== undefined ? regexMatch[wrapGroup] : regexMatch[0] + // Skip if capture group didn't match + if (!capturedText) continue + const start = + wrapGroup !== undefined ? regexMatch.index + regexMatch[0].indexOf(capturedText) : regexMatch.index + matches.push({ + start, + end: start + capturedText.length, + text: capturedText, + className, + }) + } + } + + // Sort matches by position and filter overlapping ones + matches.sort((a, b) => a.start - b.start) + const filteredMatches: Match[] = [] + for (const m of matches) { + const lastMatch = filteredMatches[filteredMatches.length - 1] + if (!lastMatch || m.start >= lastMatch.end) { + filteredMatches.push(m) + } + } + + // Build result with highlighted spans + const result: React.ReactNode[] = [] + let currentPos = 0 + + for (const [i, m] of filteredMatches.entries()) { + // Add text before this match + if (m.start > currentPos) { + result.push(line.slice(currentPos, m.start)) + } + // Add highlighted match + result.push( + + {m.text} + , + ) + currentPos = m.end + } + + // Add remaining text + if (currentPos < line.length) { + result.push(line.slice(currentPos)) + } + + return result.length > 0 ? result : [line] +} + +// Determine the visual style for a log line based on its content +function getLineStyle(line: string): string { + if (line.includes("ERROR")) return "bg-red-950/30 border-l-2 border-red-500" + if (line.includes("WARN") || line.includes("WARNING")) return "bg-yellow-950/20 border-l-2 border-yellow-500" + if (line.includes("taskToolFailed")) return "bg-red-950/30 border-l-2 border-red-500" + if (line.includes("taskStarted") || line.includes("taskCreated")) return "bg-purple-950/20" + if (line.includes("EvalPass")) return "bg-green-950/30 border-l-2 border-green-500" + if (line.includes("EvalFail")) return "bg-red-950/30 border-l-2 border-red-500" + if (line.includes("taskCompleted") || line.includes("taskAborted")) return "bg-blue-950/20" + return "" +} + +// Format log content with basic highlighting (XSS-safe - no dangerouslySetInnerHTML) +function formatLogContent(log: string): React.ReactNode[] { + const lines = log.split("\n") + const baselineMs = extractFirstTimestamp(log) + + return lines.map((line, index) => { + if (!line.trim()) { + return ( +
+ {" "} +
+ ) + } + + const parsed = simplifyLogLine(line, baselineMs) + const lineStyle = getLineStyle(line) + + return ( +
+ {/* Elapsed time */} + + {parsed.timestamp} + + {/* Log content - pl-12 ensures wrapped lines are indented under the timestamp */} + + {formatLine(parsed.simplified)} + +
+ ) + }) +} + export function Run({ run }: { run: Run }) { const runStatus = useRunStatus(run) - const { tasks, tokenUsage, usageUpdatedAt } = runStatus + const { tasks, tokenUsage, toolUsage, usageUpdatedAt, heartbeat, runners } = runStatus + + const [selectedTask, setSelectedTask] = useState(null) + const [taskLog, setTaskLog] = useState(null) + const [isLoadingLog, setIsLoadingLog] = useState(false) + const [copied, setCopied] = useState(false) + const [showKillDialog, setShowKillDialog] = useState(false) + const [isKilling, setIsKilling] = useState(false) + + // Determine if run is still active (has heartbeat or runners) + const isRunActive = !run.taskMetricsId && (!!heartbeat || (runners && runners.length > 0)) + + const onKillRun = useCallback(async () => { + setIsKilling(true) + try { + const result = await killRun(run.id) + if (result.killedContainers.length > 0) { + toast.success(`Killed ${result.killedContainers.length} container(s)`) + } else if (result.errors.length === 0) { + toast.info("No running containers found") + } else { + toast.error(result.errors.join(", ")) + } + } catch (error) { + console.error("Failed to kill run:", error) + toast.error("Failed to kill run") + } finally { + setIsKilling(false) + setShowKillDialog(false) + } + }, [run.id]) + + const onCopyLog = useCallback(async () => { + if (!taskLog) return + + try { + await navigator.clipboard.writeText(taskLog) + setCopied(true) + toast.success("Log copied to clipboard") + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error("Failed to copy log:", error) + toast.error("Failed to copy log") + } + }, [taskLog]) + + // Handle ESC key to close the dialog + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && selectedTask) { + setSelectedTask(null) + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [selectedTask]) + + const onViewTaskLog = useCallback( + async (task: Task) => { + // Only allow viewing logs for tasks that have started + if (!task.startedAt && !tokenUsage.get(task.id)) { + toast.error("Task has not started yet") + return + } + + setSelectedTask(task) + setIsLoadingLog(true) + setTaskLog(null) + + try { + const response = await fetch(`/api/runs/${run.id}/logs/${task.id}`) + + if (!response.ok) { + const error = await response.json() + toast.error(error.error || "Failed to load log") + setSelectedTask(null) + return + } + + const data = await response.json() + setTaskLog(data.logContent) + } catch (error) { + console.error("Error loading task log:", error) + toast.error("Failed to load log") + setSelectedTask(null) + } finally { + setIsLoadingLog(false) + } + }, + [run.id, tokenUsage], + ) const taskMetrics: Record = useMemo(() => { + // Reference usageUpdatedAt to trigger recomputation when Map contents change + void usageUpdatedAt const metrics: Record = {} tasks?.forEach((task) => { - const usage = tokenUsage.get(task.id) + const streamingUsage = tokenUsage.get(task.id) + const dbMetrics = task.taskMetrics - if (task.finishedAt && task.taskMetrics) { - metrics[task.id] = task.taskMetrics - } else if (usage) { + // For finished tasks, prefer DB values but fall back to streaming values + // This handles race conditions during timeout where DB might not have latest data + if (task.finishedAt) { + // Check if DB metrics have meaningful values (not just default/empty) + const dbHasData = dbMetrics && (dbMetrics.tokensIn > 0 || dbMetrics.tokensOut > 0 || dbMetrics.cost > 0) + if (dbHasData) { + metrics[task.id] = dbMetrics + } else if (streamingUsage) { + // Fall back to streaming values if DB is empty/stale + metrics[task.id] = { + tokensIn: streamingUsage.totalTokensIn, + tokensOut: streamingUsage.totalTokensOut, + tokensContext: streamingUsage.contextTokens, + duration: streamingUsage.duration ?? 0, + cost: streamingUsage.totalCost, + } + } + } else if (streamingUsage) { + // For running tasks, use streaming values metrics[task.id] = { - tokensIn: usage.totalTokensIn, - tokensOut: usage.totalTokensOut, - tokensContext: usage.contextTokens, - duration: usage.duration ?? 0, - cost: usage.totalCost, + tokensIn: streamingUsage.totalTokensIn, + tokensOut: streamingUsage.totalTokensOut, + tokensContext: streamingUsage.contextTokens, + duration: streamingUsage.duration ?? 0, + cost: streamingUsage.totalCost, } } }) return metrics - // eslint-disable-next-line react-hooks/exhaustive-deps }, [tasks, tokenUsage, usageUpdatedAt]) + // Collect all unique tool names from all tasks and sort by total attempts + const toolColumns = useMemo(() => { + // Reference usageUpdatedAt to trigger recomputation when Map contents change + void usageUpdatedAt + if (!tasks) return [] + + const toolTotals = new Map() + + for (const task of tasks) { + // Get both DB and streaming values + const dbToolUsage = task.taskMetrics?.toolUsage + const streamingToolUsage = toolUsage.get(task.id) + + // For finished tasks, prefer DB values but fall back to streaming values + // For running tasks, use streaming values + // This handles race conditions during timeout where DB might not have latest data + const taskToolUsage = task.finishedAt + ? dbToolUsage && Object.keys(dbToolUsage).length > 0 + ? dbToolUsage + : streamingToolUsage + : streamingToolUsage + + if (taskToolUsage) { + for (const [toolName, usage] of Object.entries(taskToolUsage)) { + const tool = toolName as ToolName + const current = toolTotals.get(tool) ?? 0 + toolTotals.set(tool, current + usage.attempts) + } + } + } + + // Sort by total attempts descending + return Array.from(toolTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([name]): ToolName => name) + // toolUsage ref is stable; usageUpdatedAt triggers recomputation when Map contents change + }, [tasks, toolUsage, usageUpdatedAt]) + + // Compute aggregate stats + const stats = useMemo(() => { + // Reference usageUpdatedAt to trigger recomputation when Map contents change + void usageUpdatedAt + if (!tasks) return null + + const passed = tasks.filter((t) => t.passed === true).length + const failed = tasks.filter((t) => t.passed === false).length + const completed = passed + failed + + let totalTokensIn = 0 + let totalTokensOut = 0 + let totalCost = 0 + let totalDuration = 0 + + // Aggregate tool usage from all tasks (both finished and running) + const toolUsageAggregate: ToolUsage = {} + + for (const task of tasks) { + const metrics = taskMetrics[task.id] + if (metrics) { + totalTokensIn += metrics.tokensIn + totalTokensOut += metrics.tokensOut + totalCost += metrics.cost + totalDuration += metrics.duration + } + + // Aggregate tool usage: prefer DB values for finished tasks, fall back to streaming values + // This handles race conditions during timeout where DB might not have latest data + const dbToolUsage = task.taskMetrics?.toolUsage + const streamingToolUsage = toolUsage.get(task.id) + const taskToolUsage = task.finishedAt + ? dbToolUsage && Object.keys(dbToolUsage).length > 0 + ? dbToolUsage + : streamingToolUsage + : streamingToolUsage + + if (taskToolUsage) { + for (const [key, usage] of Object.entries(taskToolUsage)) { + const tool = key as keyof ToolUsage + if (!toolUsageAggregate[tool]) { + toolUsageAggregate[tool] = { attempts: 0, failures: 0 } + } + toolUsageAggregate[tool].attempts += usage.attempts + toolUsageAggregate[tool].failures += usage.failures + } + } + } + + return { + passed, + failed, + completed, + passRate: completed > 0 ? ((passed / completed) * 100).toFixed(1) : null, + totalTokensIn, + totalTokensOut, + totalCost, + totalDuration, + toolUsage: toolUsageAggregate, + } + // Map refs are stable; usageUpdatedAt triggers recomputation when Map contents change + }, [tasks, taskMetrics, toolUsage, usageUpdatedAt]) + + // Calculate elapsed time (wall-clock time from run creation to completion or now) + const elapsedTime = useMemo(() => { + // Reference usageUpdatedAt to trigger recomputation for live elapsed time updates + void usageUpdatedAt + if (!tasks || tasks.length === 0) return null + + const startTime = new Date(run.createdAt).getTime() + + // If run is complete, find the latest finishedAt from tasks + if (run.taskMetricsId) { + const latestFinish = tasks.reduce((latest, task) => { + if (task.finishedAt) { + const finishTime = new Date(task.finishedAt).getTime() + return finishTime > latest ? finishTime : latest + } + return latest + }, startTime) + return latestFinish - startTime + } + + // If still running, use current time + return Date.now() - startTime + }, [tasks, run.createdAt, run.taskMetricsId, usageUpdatedAt]) + return ( <>
-
-
-
{run.model}
- {run.description &&
{run.description}
} + {stats && ( +
+ {/* Provider, Model title and status */} +
+ {run.settings?.apiProvider && ( + {run.settings.apiProvider} + )} +
{run.model}
+ + {run.description && ( + - {run.description} + )} + {isRunActive && ( + + + + + Stop all containers for this run + + )} +
+ {/* Main Stats Row */} +
+ {/* Passed/Failed */} +
+
+ {stats.passed} + / + {stats.failed} +
+
Passed / Failed
+
+ + {/* Pass Rate */} +
+
= 80 + ? "text-yellow-500" + : "text-red-500" + }`}> + {stats.passRate ? `${stats.passRate}%` : "-"} +
+
Pass Rate
+
+ + {/* Tokens */} +
+
+ {formatTokens(stats.totalTokensIn)} + / + {formatTokens(stats.totalTokensOut)} +
+
Tokens In / Out
+
+ + {/* Cost */} +
+
{formatCurrency(stats.totalCost)}
+
Cost
+
+ + {/* Duration */} +
+
+ {stats.totalDuration > 0 ? formatDuration(stats.totalDuration) : "-"} +
+
Duration
+
+ + {/* Elapsed Time */} +
+
+ {elapsedTime !== null ? formatDuration(elapsedTime) : "-"} +
+
Elapsed
+
+
+ + {/* Tool Usage Row */} + {Object.keys(stats.toolUsage).length > 0 && ( +
+ {Object.entries(stats.toolUsage) + .sort(([, a], [, b]) => b.attempts - a.attempts) + .map(([toolName, usage]) => { + const abbr = getToolAbbreviation(toolName) + const successRate = + usage.attempts > 0 + ? ((usage.attempts - usage.failures) / usage.attempts) * 100 + : 100 + const rateColor = + successRate === 100 + ? "text-green-500" + : successRate >= 80 + ? "text-yellow-500" + : "text-red-500" + return ( + + +
+ + {abbr} + + {usage.attempts} + + {formatToolUsageSuccessRate(usage)} + +
+
+ {toolName} +
+ ) + })} +
+ )}
- {!run.taskMetricsId && } -
+ )} {!tasks ? ( ) : ( @@ -60,53 +646,213 @@ export function Run({ run }: { run: Run }) { Exercise Tokens In / Out Context + {toolColumns.map((toolName) => ( + + + {getToolAbbreviation(toolName)} + {toolName} + + + ))} Duration Cost - {tasks.map((task) => ( - - -
- -
- {task.language}/{task.exercise} -
-
-
- {taskMetrics[task.id] ? ( - <> - -
-
{formatTokens(taskMetrics[task.id]!.tokensIn)}
/ -
{formatTokens(taskMetrics[task.id]!.tokensOut)}
+ {tasks.map((task) => { + const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id) + return ( + hasStarted && onViewTaskLog(task)}> + +
+ +
+ + {task.language}/{task.exercise} + {task.iteration > 1 && ( + + (#{task.iteration}) + + )} + + {hasStarted && ( + + + + + Click to view log + + )}
- - - {formatTokens(taskMetrics[task.id]!.tokensContext)} - - - {taskMetrics[task.id]!.duration - ? formatDuration(taskMetrics[task.id]!.duration) - : "-"} - - - {formatCurrency(taskMetrics[task.id]!.cost)} - - - ) : ( - - )} - - ))} +
+
+ {taskMetrics[task.id] ? ( + <> + +
+
{formatTokens(taskMetrics[task.id]!.tokensIn)}
/ +
{formatTokens(taskMetrics[task.id]!.tokensOut)}
+
+
+ + {formatTokens(taskMetrics[task.id]!.tokensContext)} + + {toolColumns.map((toolName) => { + // Use DB values for finished tasks, but fall back to streaming values + // if DB values are missing (handles race condition during timeout) + const dbUsage = task.taskMetrics?.toolUsage?.[toolName] + const streamingUsage = toolUsage.get(task.id)?.[toolName] + const usage = task.finishedAt + ? (dbUsage ?? streamingUsage) + : streamingUsage + + const successRate = + usage && usage.attempts > 0 + ? ((usage.attempts - usage.failures) / usage.attempts) * 100 + : 100 + const rateColor = + successRate === 100 + ? "text-muted-foreground" + : successRate >= 80 + ? "text-yellow-500" + : "text-red-500" + return ( + + {usage ? ( +
+ + {usage.attempts} + + + {formatToolUsageSuccessRate(usage)} + +
+ ) : ( + - + )} +
+ ) + })} + + {taskMetrics[task.id]!.duration + ? formatDuration(taskMetrics[task.id]!.duration) + : "-"} + + + {formatCurrency(taskMetrics[task.id]!.cost)} + + + ) : ( + + )} +
+ ) + })} )}
+ + {/* Task Log Dialog - Full Screen */} + setSelectedTask(null)}> + + +
+ + + {selectedTask?.language}/{selectedTask?.exercise} + {selectedTask?.iteration && selectedTask.iteration > 1 && ( + (#{selectedTask.iteration}) + )} + + ( + {selectedTask?.passed === true + ? "Passed" + : selectedTask?.passed === false + ? "Failed" + : "Running"} + ) + + + {taskLog && ( + + )} +
+
+
+ {isLoadingLog ? ( +
+ +
+ ) : taskLog ? ( + +
+ {formatLogContent(taskLog)} +
+
+ ) : ( +
+ Log file not available (may have been cleared) +
+ )} +
+
+
+ + {/* Kill Run Confirmation Dialog */} + + + + Kill Run? + + This will stop the controller and all task runner containers for this run. Any running tasks + will be terminated immediately. This action cannot be undone. + + + + Cancel + + {isKilling ? ( + <> + + Killing... + + ) : ( + "Kill Run" + )} + + + + ) } diff --git a/apps/web-evals/src/app/runs/new/new-run.tsx b/apps/web-evals/src/app/runs/new/new-run.tsx index 41d35f3c4c..be015ac8ca 100644 --- a/apps/web-evals/src/app/runs/new/new-run.tsx +++ b/apps/web-evals/src/app/runs/new/new-run.tsx @@ -1,39 +1,53 @@ "use client" -import { useCallback, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import { z } from "zod" import { useQuery } from "@tanstack/react-query" import { useForm, FormProvider } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" -import fuzzysort from "fuzzysort" import { toast } from "sonner" -import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal, CircleCheck } from "lucide-react" +import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal, Info, Plus, Minus } from "lucide-react" -import { globalSettingsSchema, providerSettingsSchema, EVALS_SETTINGS, getModelId } from "@roo-code/types" +import { + globalSettingsSchema, + providerSettingsSchema, + EVALS_SETTINGS, + getModelId, + type ProviderSettings, + type GlobalSettings, +} from "@roo-code/types" import { createRun } from "@/actions/runs" import { getExercises } from "@/actions/exercises" + import { - createRunSchema, type CreateRun, - MODEL_DEFAULT, + createRunSchema, CONCURRENCY_MIN, CONCURRENCY_MAX, CONCURRENCY_DEFAULT, TIMEOUT_MIN, TIMEOUT_MAX, TIMEOUT_DEFAULT, + ITERATIONS_MIN, + ITERATIONS_MAX, + ITERATIONS_DEFAULT, } from "@/lib/schemas" import { cn } from "@/lib/utils" + import { useOpenRouterModels } from "@/hooks/use-open-router-models" +import { useRooCodeCloudModels } from "@/hooks/use-roo-code-cloud-models" + import { Button, + Checkbox, FormControl, FormField, FormItem, FormLabel, FormMessage, + Input, Textarea, Tabs, TabsList, @@ -48,36 +62,78 @@ import { Popover, PopoverContent, PopoverTrigger, - ScrollArea, - ScrollBar, Slider, + Label, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui" import { SettingsDiff } from "./settings-diff" +type ImportedSettings = { + apiConfigs: Record + globalSettings: GlobalSettings + currentApiConfigName: string +} + +// Type for a model selection entry +type ModelSelection = { + id: string + model: string + popoverOpen: boolean +} + +// Type for a config selection entry (for import mode) +type ConfigSelection = { + id: string + configName: string + popoverOpen: boolean +} + export function NewRun() { const router = useRouter() - const [mode, setMode] = useState<"openrouter" | "settings">("openrouter") - const [modelSearchValue, setModelSearchValue] = useState("") - const [modelPopoverOpen, setModelPopoverOpen] = useState(false) + const [provider, setModelSource] = useState<"roo" | "openrouter" | "other">("other") + const [useNativeToolProtocol, setUseNativeToolProtocol] = useState(true) + const [commandExecutionTimeout, setCommandExecutionTimeout] = useState(20) + const [terminalShellIntegrationTimeout, setTerminalShellIntegrationTimeout] = useState(30) // seconds - const modelSearchResultsRef = useRef>(new Map()) - const modelSearchValueRef = useRef("") + // State for multiple model selections + const [modelSelections, setModelSelections] = useState([ + { id: crypto.randomUUID(), model: "", popoverOpen: false }, + ]) + + // State for imported settings with multiple config selections + const [importedSettings, setImportedSettings] = useState(null) + const [configSelections, setConfigSelections] = useState([ + { id: crypto.randomUUID(), configName: "", popoverOpen: false }, + ]) + + const openRouter = useOpenRouterModels() + const rooCodeCloud = useRooCodeCloudModels() + const models = provider === "openrouter" ? openRouter.data : rooCodeCloud.data + const searchValue = provider === "openrouter" ? openRouter.searchValue : rooCodeCloud.searchValue + const setSearchValue = provider === "openrouter" ? openRouter.setSearchValue : rooCodeCloud.setSearchValue + const onFilter = provider === "openrouter" ? openRouter.onFilter : rooCodeCloud.onFilter - const models = useOpenRouterModels() const exercises = useQuery({ queryKey: ["getExercises"], queryFn: () => getExercises() }) + // State for selected exercises (needed for language toggle buttons) + const [selectedExercises, setSelectedExercises] = useState([]) + const form = useForm({ resolver: zodResolver(createRunSchema), defaultValues: { - model: MODEL_DEFAULT, + model: "", description: "", suite: "full", exercises: [], settings: undefined, concurrency: CONCURRENCY_DEFAULT, timeout: TIMEOUT_DEFAULT, + iterations: ITERATIONS_DEFAULT, + jobToken: "", }, }) @@ -88,51 +144,281 @@ export function NewRun() { formState: { isSubmitting }, } = form - const [model, suite, settings] = watch(["model", "suite", "settings", "concurrency"]) + const [suite, settings] = watch(["suite", "settings", "concurrency"]) + + // Load settings from localStorage on mount + useEffect(() => { + const savedConcurrency = localStorage.getItem("evals-concurrency") + if (savedConcurrency) { + const parsed = parseInt(savedConcurrency, 10) + if (!isNaN(parsed) && parsed >= CONCURRENCY_MIN && parsed <= CONCURRENCY_MAX) { + setValue("concurrency", parsed) + } + } + const savedTimeout = localStorage.getItem("evals-timeout") + if (savedTimeout) { + const parsed = parseInt(savedTimeout, 10) + if (!isNaN(parsed) && parsed >= TIMEOUT_MIN && parsed <= TIMEOUT_MAX) { + setValue("timeout", parsed) + } + } + const savedCommandTimeout = localStorage.getItem("evals-command-execution-timeout") + if (savedCommandTimeout) { + const parsed = parseInt(savedCommandTimeout, 10) + if (!isNaN(parsed) && parsed >= 20 && parsed <= 60) { + setCommandExecutionTimeout(parsed) + } + } + const savedShellTimeout = localStorage.getItem("evals-shell-integration-timeout") + if (savedShellTimeout) { + const parsed = parseInt(savedShellTimeout, 10) + if (!isNaN(parsed) && parsed >= 30 && parsed <= 60) { + setTerminalShellIntegrationTimeout(parsed) + } + } + // Load saved exercises selection + const savedSuite = localStorage.getItem("evals-suite") + if (savedSuite === "partial") { + setValue("suite", "partial") + const savedExercises = localStorage.getItem("evals-exercises") + if (savedExercises) { + try { + const parsed = JSON.parse(savedExercises) as string[] + if (Array.isArray(parsed)) { + setSelectedExercises(parsed) + setValue("exercises", parsed) + } + } catch { + // Invalid JSON, ignore + } + } + } + }, [setValue]) + + // Extract unique languages from exercises + const languages = useMemo(() => { + if (!exercises.data) return [] + const langs = new Set() + for (const path of exercises.data) { + const lang = path.split("/")[0] + if (lang) langs.add(lang) + } + return Array.from(langs).sort() + }, [exercises.data]) + + // Get exercises for a specific language + const getExercisesForLanguage = useCallback( + (lang: string) => { + if (!exercises.data) return [] + return exercises.data.filter((path) => path.startsWith(`${lang}/`)) + }, + [exercises.data], + ) + + // Toggle all exercises for a language + const toggleLanguage = useCallback( + (lang: string) => { + const langExercises = getExercisesForLanguage(lang) + const allSelected = langExercises.every((ex) => selectedExercises.includes(ex)) + + let newSelected: string[] + if (allSelected) { + // Remove all exercises for this language + newSelected = selectedExercises.filter((ex) => !ex.startsWith(`${lang}/`)) + } else { + // Add all exercises for this language (avoiding duplicates) + const existing = new Set(selectedExercises) + for (const ex of langExercises) { + existing.add(ex) + } + newSelected = Array.from(existing) + } + + setSelectedExercises(newSelected) + setValue("exercises", newSelected) + localStorage.setItem("evals-exercises", JSON.stringify(newSelected)) + }, + [getExercisesForLanguage, selectedExercises, setValue], + ) + + // Check if all exercises for a language are selected + const isLanguageSelected = useCallback( + (lang: string) => { + const langExercises = getExercisesForLanguage(lang) + return langExercises.length > 0 && langExercises.every((ex) => selectedExercises.includes(ex)) + }, + [getExercisesForLanguage, selectedExercises], + ) + + // Check if some (but not all) exercises for a language are selected + const isLanguagePartiallySelected = useCallback( + (lang: string) => { + const langExercises = getExercisesForLanguage(lang) + const selectedCount = langExercises.filter((ex) => selectedExercises.includes(ex)).length + return selectedCount > 0 && selectedCount < langExercises.length + }, + [getExercisesForLanguage, selectedExercises], + ) + + // Add a new model selection + const addModelSelection = useCallback(() => { + setModelSelections((prev) => [...prev, { id: crypto.randomUUID(), model: "", popoverOpen: false }]) + }, []) + + // Remove a model selection + const removeModelSelection = useCallback((id: string) => { + setModelSelections((prev) => prev.filter((s) => s.id !== id)) + }, []) + + // Update a model selection + const updateModelSelection = useCallback( + (id: string, model: string) => { + setModelSelections((prev) => prev.map((s) => (s.id === id ? { ...s, model, popoverOpen: false } : s))) + // Also set the form model field for validation (use first non-empty model) + setValue("model", model) + }, + [setValue], + ) + + // Toggle popover for a model selection + const toggleModelPopover = useCallback((id: string, open: boolean) => { + setModelSelections((prev) => prev.map((s) => (s.id === id ? { ...s, popoverOpen: open } : s))) + }, []) + + // Add a new config selection + const addConfigSelection = useCallback(() => { + setConfigSelections((prev) => [...prev, { id: crypto.randomUUID(), configName: "", popoverOpen: false }]) + }, []) + + // Remove a config selection + const removeConfigSelection = useCallback((id: string) => { + setConfigSelections((prev) => prev.filter((s) => s.id !== id)) + }, []) + + // Update a config selection + const updateConfigSelection = useCallback( + (id: string, configName: string) => { + setConfigSelections((prev) => prev.map((s) => (s.id === id ? { ...s, configName, popoverOpen: false } : s))) + // Also update the form settings for the first config (for validation) + if (importedSettings) { + const providerSettings = importedSettings.apiConfigs[configName] ?? {} + setValue("model", getModelId(providerSettings) ?? "") + setValue("settings", { ...EVALS_SETTINGS, ...providerSettings, ...importedSettings.globalSettings }) + } + }, + [importedSettings, setValue], + ) + + // Toggle popover for a config selection + const toggleConfigPopover = useCallback((id: string, open: boolean) => { + setConfigSelections((prev) => prev.map((s) => (s.id === id ? { ...s, popoverOpen: open } : s))) + }, []) const onSubmit = useCallback( async (values: CreateRun) => { try { - if (mode === "openrouter") { - values.settings = { ...(values.settings || {}), openRouterModelId: model } + // Validate jobToken for Roo Code Cloud provider + if (provider === "roo" && !values.jobToken?.trim()) { + toast.error("Roo Code Cloud Token is required") + return } - const { id } = await createRun(values) - router.push(`/runs/${id}`) + // Determine which selections to use based on provider + const selectionsToLaunch: { model: string; configName?: string }[] = [] + + if (provider === "other") { + // For import mode, use config selections + for (const config of configSelections) { + if (config.configName) { + selectionsToLaunch.push({ model: "", configName: config.configName }) + } + } + } else { + // For openrouter/roo, use model selections + for (const selection of modelSelections) { + if (selection.model) { + selectionsToLaunch.push({ model: selection.model }) + } + } + } + + if (selectionsToLaunch.length === 0) { + toast.error("Please select at least one model or config") + return + } + + // Show launching toast + const totalRuns = selectionsToLaunch.length + toast.info(totalRuns > 1 ? `Launching ${totalRuns} runs (every 20 seconds)...` : "Launching run...") + + // Launch runs with 20-second delay between each + for (let i = 0; i < selectionsToLaunch.length; i++) { + const selection = selectionsToLaunch[i]! + + // Wait 20 seconds between runs (except for the first one) + if (i > 0) { + await new Promise((resolve) => setTimeout(resolve, 20000)) + } + + const runValues = { ...values } + + if (provider === "openrouter") { + runValues.model = selection.model + runValues.settings = { + ...(runValues.settings || {}), + apiProvider: "openrouter", + openRouterModelId: selection.model, + toolProtocol: useNativeToolProtocol ? "native" : "xml", + commandExecutionTimeout, + terminalShellIntegrationTimeout: terminalShellIntegrationTimeout * 1000, + } + } else if (provider === "roo") { + runValues.model = selection.model + runValues.settings = { + ...(runValues.settings || {}), + apiProvider: "roo", + apiModelId: selection.model, + toolProtocol: useNativeToolProtocol ? "native" : "xml", + commandExecutionTimeout, + terminalShellIntegrationTimeout: terminalShellIntegrationTimeout * 1000, + } + } else if (provider === "other" && selection.configName && importedSettings) { + const providerSettings = importedSettings.apiConfigs[selection.configName] ?? {} + runValues.model = getModelId(providerSettings) ?? "" + runValues.settings = { + ...EVALS_SETTINGS, + ...providerSettings, + ...importedSettings.globalSettings, + toolProtocol: useNativeToolProtocol ? "native" : "xml", + commandExecutionTimeout, + terminalShellIntegrationTimeout: terminalShellIntegrationTimeout * 1000, + } + } + + try { + await createRun(runValues) + toast.success(`Run ${i + 1}/${totalRuns} launched`) + } catch (e) { + toast.error(`Run ${i + 1} failed: ${e instanceof Error ? e.message : "Unknown error"}`) + } + } + + // Navigate back to main evals UI + router.push("/") } catch (e) { toast.error(e instanceof Error ? e.message : "An unknown error occurred.") } }, - [mode, model, router], - ) - - const onFilterModels = useCallback( - (value: string, search: string) => { - if (modelSearchValueRef.current !== search) { - modelSearchValueRef.current = search - modelSearchResultsRef.current.clear() - - for (const { - obj: { id }, - score, - } of fuzzysort.go(search, models.data || [], { - key: "name", - })) { - modelSearchResultsRef.current.set(id, score) - } - } - - return modelSearchResultsRef.current.get(value) ?? 0 - }, - [models.data], - ) - - const onSelectModel = useCallback( - (model: string) => { - setValue("model", model) - setModelPopoverOpen(false) - }, - [setValue], + [ + provider, + modelSelections, + configSelections, + importedSettings, + router, + useNativeToolProtocol, + commandExecutionTimeout, + terminalShellIntegrationTimeout, + ], ) const onImportSettings = useCallback( @@ -156,11 +442,21 @@ export function NewRun() { }) .parse(JSON.parse(await file.text())) - const providerSettings = providerProfiles.apiConfigs[providerProfiles.currentApiConfigName] ?? {} + // Store all imported configs for user selection + setImportedSettings({ + apiConfigs: providerProfiles.apiConfigs, + globalSettings, + currentApiConfigName: providerProfiles.currentApiConfigName, + }) + // Default to the current config for the first selection + const defaultConfigName = providerProfiles.currentApiConfigName + setConfigSelections([{ id: crypto.randomUUID(), configName: defaultConfigName, popoverOpen: false }]) + + // Apply the default config + const providerSettings = providerProfiles.apiConfigs[defaultConfigName] ?? {} setValue("model", getModelId(providerSettings) ?? "") setValue("settings", { ...EVALS_SETTINGS, ...providerSettings, ...globalSettings }) - setMode("settings") event.target.value = "" } catch (e) { @@ -177,97 +473,291 @@ export function NewRun() {
-
- {mode === "openrouter" && ( - ( - - - - - - - - - - No model found. - - {models.data?.map(({ id, name }) => ( - - {name} - - - ))} - - - - - - - - )} - /> - )} + ( + + setModelSource(value as "roo" | "openrouter" | "other")}> + + Import + Roo Code Cloud + OpenRouter + + - - - - {settings && ( - - <> -
- -
- Imported valid Roo Code settings. Showing differences from default - settings. + {provider === "other" ? ( +
+ + + + {importedSettings && Object.keys(importedSettings.apiConfigs).length > 0 && ( +
+ + {configSelections.map((selection, index) => ( +
+ + toggleConfigPopover(selection.id, open) + }> + + + + + + + + No config found. + + {Object.keys( + importedSettings.apiConfigs, + ).map((configName) => ( + + updateConfigSelection( + selection.id, + configName, + ) + }> + {configName} + {configName === + importedSettings.currentApiConfigName && ( + + (default) + + )} + + + ))} + + + + + + {index === configSelections.length - 1 ? ( + + ) : ( + + )} +
+ ))} +
+ )} + +
+ +
+ +
+
+ + {settings && ( + + )} +
+ ) : ( + <> +
+ {modelSelections.map((selection, index) => ( +
+ toggleModelPopover(selection.id, open)}> + + + + + + + + No model found. + + {models?.map(({ id, name }) => ( + + updateModelSelection( + selection.id, + id, + ) + }> + {name} + + + ))} + + + + + + {index === modelSelections.length - 1 ? ( + + ) : ( + + )} +
+ ))} +
+ +
+ +
+
- - - + )} + + + + )} + /> + + {provider === "roo" && ( + ( + +
+ Roo Code Cloud Token + + + + + +

+ If you have access to the Roo Code Cloud repository and the + decryption key for the .env.* files, generate a token with: +

+ + pnpm --filter @roo-code-cloud/auth production:create-auth-token + [email] [org] [ttl] + +
+
+
+ + + + +
)} - - -
+ /> + )} ( Exercises - setValue("suite", value as "full" | "partial")}> - - All - Some - - +
+ { + setValue("suite", value as "full" | "partial") + localStorage.setItem("evals-suite", value) + if (value === "full") { + setSelectedExercises([]) + setValue("exercises", []) + localStorage.removeItem("evals-exercises") + } + }}> + + All + Some + + + {suite === "partial" && languages.length > 0 && ( +
+ {languages.map((lang) => ( + + ))} +
+ )} +
{suite === "partial" && ( ({ value: path, label: path })) || []} - onValueChange={(value) => setValue("exercises", value)} + value={selectedExercises} + onValueChange={(value) => { + setSelectedExercises(value) + setValue("exercises", value) + localStorage.setItem("evals-exercises", JSON.stringify(value)) + }} placeholder="Select" variant="inverted" maxCount={4} @@ -297,51 +823,153 @@ export function NewRun() { )} /> - ( - - Concurrency - -
- field.onChange(value[0])} - /> -
{field.value}
-
-
- -
- )} - /> + {/* Concurrency, Timeout, and Iterations in a 3-column row */} +
+ ( + + Concurrency + +
+ { + field.onChange(value[0]) + localStorage.setItem("evals-concurrency", String(value[0])) + }} + /> +
{field.value}
+
+
+ +
+ )} + /> - ( - - Timeout (Minutes) - -
- field.onChange(value[0])} - /> -
{field.value}
-
-
- -
- )} - /> + ( + + Timeout (Minutes) + +
+ { + field.onChange(value[0]) + localStorage.setItem("evals-timeout", String(value[0])) + }} + /> +
{field.value}
+
+
+ +
+ )} + /> + + ( + + Iterations + +
+ { + field.onChange(value[0]) + }} + /> +
{field.value}
+
+
+ +
+ )} + /> +
+ + {/* Terminal timeouts in a 2-column row */} +
+ +
+ + + + + + +

+ Maximum time in seconds to wait for terminal command execution to complete + before timing out. This applies to commands run via the execute_command + tool. +

+
+
+
+
+ { + if (value !== undefined) { + setCommandExecutionTimeout(value) + localStorage.setItem("evals-command-execution-timeout", String(value)) + } + }} + /> +
{commandExecutionTimeout}
+
+
+ + +
+ + + + + + +

+ Maximum time in seconds to wait for shell integration to initialize when + opening a new terminal. +

+
+
+
+
+ { + if (value !== undefined) { + setTerminalShellIntegrationTimeout(value) + localStorage.setItem("evals-shell-integration-timeout", String(value)) + } + }} + /> +
{terminalShellIntegrationTimeout}
+
+
+
[] +export const ROO_CODE_SETTINGS_KEYS = [ + ...new Set([...GLOBAL_SETTINGS_KEYS, ...PROVIDER_SETTINGS_KEYS]), +] as Keys[] -type SettingsDiffProps = HTMLAttributes & { +type SettingsDiffProps = { defaultSettings: RooCodeSettings customSettings: RooCodeSettings } @@ -14,53 +14,45 @@ type SettingsDiffProps = HTMLAttributes & { export function SettingsDiff({ customSettings: { experiments: customExperiments, ...customSettings }, defaultSettings: { experiments: defaultExperiments, ...defaultSettings }, - className, - ...props }: SettingsDiffProps) { const defaults = { ...defaultSettings, ...defaultExperiments } const custom = { ...customSettings, ...customExperiments } return ( -
-
Setting
-
Default
-
Custom
- {ROO_CODE_SETTINGS_KEYS.map((key) => { - const defaultValue = defaults[key as keyof typeof defaults] - const customValue = custom[key as keyof typeof custom] - const isDefault = JSON.stringify(defaultValue) === JSON.stringify(customValue) +
+ + + + Setting + Default + Custom + + + + {ROO_CODE_SETTINGS_KEYS.map((key) => { + const defaultValue = JSON.stringify(defaults[key as keyof typeof defaults], null, 2) + const customValue = JSON.stringify(custom[key as keyof typeof custom], null, 2) - return isDefault ? null : ( - - ) - })} + return defaultValue === customValue || + (isEmpty(defaultValue) && isEmpty(customValue)) ? null : ( + + + {key} + + + {defaultValue} + + + {customValue} + + + ) + })} + +
) } -type SettingDiffProps = HTMLAttributes & { - name: string - defaultValue?: string - customValue?: string -} - -export function SettingDiff({ name, defaultValue, customValue, ...props }: SettingDiffProps) { - return ( - -
- {name} -
-
-				{defaultValue}
-			
-
-				{customValue}
-			
-
- ) -} +const isEmpty = (value: string | undefined) => + value === undefined || value === "" || value === "null" || value === '""' || value === "[]" || value === "{}" diff --git a/apps/web-evals/src/components/home/run.tsx b/apps/web-evals/src/components/home/run.tsx index c35673885c..99950bae43 100644 --- a/apps/web-evals/src/components/home/run.tsx +++ b/apps/web-evals/src/components/home/run.tsx @@ -1,16 +1,29 @@ import { useCallback, useState, useRef } from "react" import Link from "next/link" -import { Ellipsis, ClipboardList, Copy, Check, LoaderCircle, Trash } from "lucide-react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { Ellipsis, ClipboardList, Copy, Check, LoaderCircle, Trash, Settings, FileDown, StickyNote } from "lucide-react" import type { Run as EvalsRun, TaskMetrics as EvalsTaskMetrics } from "@roo-code/evals" +import type { ToolName } from "@roo-code/types" -import { deleteRun } from "@/actions/runs" -import { formatCurrency, formatDuration, formatTokens, formatToolUsageSuccessRate } from "@/lib/formatters" +import { deleteRun, updateRunDescription } from "@/actions/runs" +import { + formatCurrency, + formatDateTime, + formatDuration, + formatTokens, + formatToolUsageSuccessRate, +} from "@/lib/formatters" import { useCopyRun } from "@/hooks/use-copy-run" import { Button, TableCell, TableRow, + Textarea, + Tooltip, + TooltipContent, + TooltipTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -23,18 +36,89 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + ScrollArea, } from "@/components/ui" type RunProps = { run: EvalsRun taskMetrics: EvalsTaskMetrics | null + toolColumns: ToolName[] + consolidatedToolColumns: string[] } -export function Run({ run, taskMetrics }: RunProps) { +export function Run({ run, taskMetrics, toolColumns, consolidatedToolColumns }: RunProps) { + const router = useRouter() const [deleteRunId, setDeleteRunId] = useState() + const [showSettings, setShowSettings] = useState(false) + const [isExportingLogs, setIsExportingLogs] = useState(false) + const [showNotesDialog, setShowNotesDialog] = useState(false) + const [editingDescription, setEditingDescription] = useState(run.description ?? "") + const [isSavingNotes, setIsSavingNotes] = useState(false) const continueRef = useRef(null) const { isPending, copyRun, copied } = useCopyRun(run.id) + const hasDescription = Boolean(run.description && run.description.trim().length > 0) + + const handleSaveDescription = useCallback(async () => { + setIsSavingNotes(true) + try { + const result = await updateRunDescription(run.id, editingDescription.trim() || null) + if (result.success) { + toast.success("Description saved") + setShowNotesDialog(false) + router.refresh() + } else { + toast.error("Failed to save description") + } + } catch (error) { + console.error("Error saving description:", error) + toast.error("Failed to save description") + } finally { + setIsSavingNotes(false) + } + }, [run.id, editingDescription, router]) + + const onExportFailedLogs = useCallback(async () => { + if (run.failed === 0) { + toast.error("No failed tasks to export") + return + } + + setIsExportingLogs(true) + try { + const response = await fetch(`/api/runs/${run.id}/logs/failed`) + + if (!response.ok) { + const error = await response.json() + toast.error(error.error || "Failed to export logs") + return + } + + // Download the zip file + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `run-${run.id}-failed-logs.zip` + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + + toast.success("Failed logs exported successfully") + } catch (error) { + console.error("Error exporting logs:", error) + toast.error("Failed to export logs") + } finally { + setIsExportingLogs(false) + } + }, [run.id, run.failed]) + const onConfirmDelete = useCallback(async () => { if (!deleteRunId) { return @@ -48,86 +132,233 @@ export function Run({ run, taskMetrics }: RunProps) { } }, [deleteRunId]) + const handleRowClick = useCallback( + (e: React.MouseEvent) => { + // Don't navigate if clicking on the dropdown menu + if ((e.target as HTMLElement).closest("[data-dropdown-trigger]")) { + return + } + router.push(`/runs/${run.id}`) + }, + [router, run.id], + ) + return ( <> - - {run.model} + + {run.model} + {run.settings?.apiProvider ?? "-"} + + {formatDateTime(run.createdAt)} + {run.passed} {run.failed} - {run.passed + run.failed > 0 && ( - {((run.passed / (run.passed + run.failed)) * 100).toFixed(1)}% - )} + {run.passed + run.failed > 0 && + (() => { + const percent = (run.passed / (run.passed + run.failed)) * 100 + const colorClass = + percent === 100 ? "text-green-500" : percent >= 80 ? "text-yellow-500" : "text-red-500" + return {percent.toFixed(1)}% + })()} {taskMetrics && ( -
-
{formatTokens(taskMetrics.tokensIn)}
/ -
{formatTokens(taskMetrics.tokensOut)}
-
- )} -
- - {taskMetrics?.toolUsage?.apply_diff && ( -
-
{taskMetrics.toolUsage.apply_diff.attempts}
-
/
-
{formatToolUsageSuccessRate(taskMetrics.toolUsage.apply_diff)}
+
+ {formatTokens(taskMetrics.tokensIn)}/ + {formatTokens(taskMetrics.tokensOut)}
)} + {consolidatedToolColumns.length > 0 && ( + + {taskMetrics?.toolUsage ? ( + (() => { + // Calculate aggregated stats for consolidated tools + let totalAttempts = 0 + let totalFailures = 0 + const breakdown: Array<{ tool: string; attempts: number; rate: string }> = [] + + for (const toolName of consolidatedToolColumns) { + const usage = taskMetrics.toolUsage[toolName as ToolName] + if (usage) { + totalAttempts += usage.attempts + totalFailures += usage.failures + const rate = + usage.attempts > 0 + ? `${Math.round(((usage.attempts - usage.failures) / usage.attempts) * 100)}%` + : "0%" + breakdown.push({ tool: toolName, attempts: usage.attempts, rate }) + } + } + + const consolidatedRate = + totalAttempts > 0 ? ((totalAttempts - totalFailures) / totalAttempts) * 100 : 100 + const rateColor = + consolidatedRate === 100 + ? "text-muted-foreground" + : consolidatedRate >= 80 + ? "text-yellow-500" + : "text-red-500" + + return totalAttempts > 0 ? ( + + +
+ {totalAttempts} + {Math.round(consolidatedRate)}% +
+
+ +
+
Consolidated Tools:
+ {breakdown.map(({ tool, attempts, rate }) => ( +
+ {tool}: + + {attempts} ({rate}) + +
+ ))} +
+
+
+ ) : ( + - + ) + })() + ) : ( + - + )} +
+ )} + {toolColumns.map((toolName) => { + const usage = taskMetrics?.toolUsage?.[toolName] + const successRate = + usage && usage.attempts > 0 ? ((usage.attempts - usage.failures) / usage.attempts) * 100 : 100 + const rateColor = + successRate === 100 + ? "text-muted-foreground" + : successRate >= 80 + ? "text-yellow-500" + : "text-red-500" + return ( + + {usage ? ( +
+ {usage.attempts} + {formatToolUsageSuccessRate(usage)} +
+ ) : ( + - + )} +
+ ) + })} {taskMetrics && formatCurrency(taskMetrics.cost)} {taskMetrics && formatDuration(taskMetrics.duration)} - - - - - - + e.stopPropagation()}> +
+ {/* Note Icon */} + + + + + + {hasDescription ? ( +
{run.description}
+ ) : ( +
No description. Click to add one.
+ )} +
+
+ + {/* More Actions Menu */} + + + + + +
+ +
View Tasks
+
+ +
+ {run.settings && ( + setShowSettings(true)}> +
+ +
View Settings
+
+
+ )} + {run.taskMetricsId && ( + copyRun()} disabled={isPending || copied}> +
+ {isPending ? ( + <> + + Copying... + + ) : copied ? ( + <> + + Copied! + + ) : ( + <> + + Copy to Production + + )} +
+
+ )} + {run.failed > 0 && ( + +
+ {isExportingLogs ? ( + <> + + Exporting... + + ) : ( + <> + + Export Failed Logs + + )} +
+
+ )} + { + setDeleteRunId(run.id) + setTimeout(() => continueRef.current?.focus(), 0) + }}>
- -
View Tasks
-
- -
- {run.taskMetricsId && ( - copyRun()} disabled={isPending || copied}> -
- {isPending ? ( - <> - - Copying... - - ) : copied ? ( - <> - - Copied! - - ) : ( - <> - - Copy to Production - - )} + +
Delete
- )} - { - setDeleteRunId(run.id) - setTimeout(() => continueRef.current?.focus(), 0) - }}> -
- -
Delete
-
-
-
-
+ + +
setDeleteRunId(undefined)}> @@ -144,6 +375,51 @@ export function Run({ run, taskMetrics }: RunProps) { + + + + Run Settings + + +
+							{JSON.stringify(run.settings, null, 2)}
+						
+
+
+
+ + {/* Notes/Description Dialog */} + + + + Run Description + +
+