diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9e22303a35..16e0c17e7a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -76,12 +76,11 @@ body: label: API Provider (optional) options: - Anthropic - - AWS Bedrock + - Amazon Bedrock - Chutes AI - DeepSeek - Featherless AI - Fireworks AI - - Glama - Google Gemini - Google Vertex AI - Groq 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/roomotes.yml b/.roo/roomotes.yml index 33f6b3bd57..0ea30b93af 100644 --- a/.roo/roomotes.yml +++ b/.roo/roomotes.yml @@ -1,25 +1,6 @@ version: "1.0" commands: - - name: Pull latest changes - run: git pull - timeout: 60 - execution_phase: task_run - name: Install dependencies run: pnpm install timeout: 60 - execution_phase: task_run - -github_events: - - event: issues.opened - action: - name: github.issue.fix - - event: issue_comment.created - action: - name: github.issue.comment.respond - - event: pull_request.opened - action: - name: github.pr.review - - event: pull_request_review_comment.created - action: - name: github.pr.comment.respond 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 86e435b6ee..85019b81f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,321 @@ # Roo Code Changelog +## [3.36.16] - 2025-12-19 + +- Fix: Normalize tool schemas for VS Code LM API to resolve error 400 when using VS Code Language Model API providers (PR #10221 by @hannesrudolph) + +## [3.36.15] - 2025-12-19 + +![3.36.15 Release - 1M Context Window Support](/releases/3.36.15-release.png) + +- Add 1M context window beta support for Claude Sonnet 4 on Vertex AI, enabling significantly larger context for complex tasks (PR #10209 by @hannesrudolph) +- Add native tool calling support for LM Studio and Qwen-Code providers, improving compatibility with local models (PR #10208 by @hannesrudolph) +- Add native tool call defaults for OpenAI-compatible providers, expanding native function calling across more configurations (PR #10213 by @hannesrudolph) +- Enable native tool calls for Requesty provider (PR #10211 by @daniel-lxs) +- Improve API error handling and visibility with clearer error messages and better user feedback (PR #10204 by @brunobergher) +- Add downloadable error diagnostics from chat errors, making it easier to troubleshoot and report issues (PR #10188 by @brunobergher) +- Fix refresh models button not properly flushing the cache, ensuring model lists update correctly (#9682 by @tl-hbk, PR #9870 by @pdecat) +- Fix additionalProperties handling for strict mode compatibility, resolving schema validation issues with certain providers (PR #10210 by @daniel-lxs) + +## [3.36.14] - 2025-12-18 + +![3.36.14 Release - Native Tool Calling for Claude on Vertex AI](/releases/3.36.14-release.png) + +- Add native tool calling support for Claude models on Vertex AI, enabling more efficient and reliable tool interactions (PR #10197 by @hannesrudolph) +- Fix JSON Schema format value stripping for OpenAI compatibility, resolving issues with unsupported format values (PR #10198 by @daniel-lxs) +- Improve "no tools used" error handling with graceful retry mechanism for better reliability when tools fail to execute (PR #10196 by @hannesrudolph) + +## [3.36.13] - 2025-12-18 + +![3.36.13 Release - Native Tool Protocol](/releases/3.36.13-release.png) + +- Change default tool protocol from XML to native for improved reliability and performance (PR #10186 by @mrubens) +- Add native tool support for VS Code Language Model API providers (PR #10191 by @daniel-lxs) +- Lock task tool protocol for consistent task resumption, ensuring tasks resume with the same protocol they started with (PR #10192 by @daniel-lxs) +- Replace edit_file tool alias with actual edit_file tool for improved diff editing capabilities (PR #9983 by @hannesrudolph) +- Fix LiteLLM router models by merging default model info for native tool calling support (PR #10187 by @daniel-lxs) +- Add PostHog exception tracking for consecutive mistake errors to improve error monitoring (PR #10193 by @daniel-lxs) + +## [3.36.12] - 2025-12-18 + +![3.36.12 Release - Better telemetry and Bedrock fixes](/releases/3.36.12-release.png) + +- Fix: Add userAgentAppId to Bedrock embedder for code indexing (#10165 by @jackrein, PR #10166 by @roomote) +- Update OpenAI and Gemini tool preferences for improved model behavior (PR #10170 by @hannesrudolph) +- Extract error messages from JSON payloads for better PostHog error grouping (PR #10163 by @daniel-lxs) + +## [3.36.11] - 2025-12-17 + +![3.36.11 Release - Native Tool Calling Enhancements](/releases/3.36.11-release.png) + +- Add support for Claude Code Provider native tool calling, improving tool execution performance and reliability (PR #10077 by @hannesrudolph) +- Enable native tool calling by default for Z.ai models for better model compatibility (PR #10158 by @app/roomote) +- Enable native tools by default for OpenAI compatible provider to improve tool calling support (PR #10159 by @daniel-lxs) +- Fix: Normalize MCP tool schemas for Bedrock and OpenAI strict mode to ensure proper tool compatibility (PR #10148 by @daniel-lxs) +- Fix: Remove dots and colons from MCP tool names for Bedrock compatibility (PR #10152 by @daniel-lxs) +- Fix: Convert tool_result to XML text when native tools disabled for Bedrock (PR #10155 by @daniel-lxs) +- Fix: Refresh Roo models cache with session token on auth state change to resolve model list refresh issues (PR #10156 by @daniel-lxs) +- Fix: Support AWS GovCloud and China region ARNs in Bedrock provider for expanded regional support (PR #10157 by @app/roomote) + +## [3.36.10] - 2025-12-17 + +![3.36.10 Release - Gemini 3 Flash Preview](/releases/3.36.10-release.png) + +- Add support for Gemini 3 Flash Preview model in the Gemini provider (PR #10151 by @hannesrudolph) +- Implement interleaved thinking mode for DeepSeek Reasoner, enabling streaming reasoning output (PR #9969 by @hannesrudolph) +- Fix: Preserve reasoning_content during tool call sequences in DeepSeek (PR #10141 by @hannesrudolph) +- Fix: Correct token counting for context truncation display (PR #9961 by @hannesrudolph) +- Update Next.js dependency to ~15.2.8 (PR #10140 by @jr) + +## [3.36.9] - 2025-12-15 + +![3.36.9 Release - Cross-Provider Compatibility](/releases/3.36.9-release.png) + +- Fix: Normalize tool call IDs for cross-provider compatibility via OpenRouter, ensuring consistent handling across different AI providers (PR #10102 by @daniel-lxs) +- Fix: Add additionalProperties: false to nested MCP tool schemas, improving schema validation and preventing unexpected properties (PR #10109 by @daniel-lxs) +- Fix: Validate tool_result IDs in delegation resume flow, preventing errors when resuming delegated tasks (PR #10135 by @daniel-lxs) +- Feat: Add full error details to streaming failure dialog, providing more comprehensive information for debugging streaming issues (PR #10131 by @roomote) +- Feat: Improve evals UI with tool groups and duration fix, enhancing the evaluation interface organization and timing accuracy (PR #10133 by @hannesrudolph) + +## [3.36.8] - 2025-12-16 + +![3.36.8 Release - Native Tools Enabled by Default](/releases/3.36.8-release.png) + +- Implement incremental token-budgeted file reading for smarter, more efficient file content retrieval (PR #10052 by @jr) +- Enable native tools by default for multiple providers including OpenAI, Azure, Google, Vertex, and more (PR #10059 by @daniel-lxs) +- Enable native tools by default for Anthropic and add telemetry tracking for tool format usage (PR #10021 by @daniel-lxs) +- Fix: Prevent race condition from deleting wrong API messages during streaming (PR #10113 by @hannesrudolph) +- Fix: Prevent duplicate MCP tools error by deduplicating servers at source (PR #10096 by @daniel-lxs) +- Remove strict ARN validation for Bedrock custom ARN users allowing more flexibility (#10108 by @wisestmumbler, PR #10110 by @roomote) +- Add metadata to error details dialog for improved debugging (PR #10050 by @roomote) +- Add configuration to control public sharing feature (PR #10105 by @mrubens) +- Remove description from Bedrock service tiers for cleaner UI (PR #10118 by @mrubens) +- Fix: Correct link to provider pricing page on web (PR #10107 by @brunobergher) + +## [3.36.7] - 2025-12-15 + +- Improve tool configuration for OpenAI models in OpenRouter (PR #10082 by @hannesrudolph) +- Capture more detailed provider-specific error information from OpenRouter for better debugging (PR #10073 by @jr) +- Add Amazon Nova 2 Lite model to Bedrock provider (#9802 by @Smartsheet-JB-Brown, PR #9830 by @roomote) +- Add AWS Bedrock service tier support (#9874 by @Smartsheet-JB-Brown, PR #9955 by @roomote) +- Remove auto-approve toggles for to-do and retry actions to simplify the approval workflow (PR #10062 by @hannesrudolph) +- Move isToolAllowedForMode out of shared directory for better code organization (PR #10089 by @cte) +- Improve run logs and formatters in web-evals for better evaluation tracking (PR #10081 by @hannesrudolph) + +## [3.36.6] - 2025-12-12 + +![3.36.6 Release - Tool Alias Support](/releases/3.36.6-release.png) + +- Add tool alias support for model-specific tool customization, allowing users to configure how tools are presented to different AI models (PR #9989 by @daniel-lxs) +- Sanitize MCP server and tool names for API compatibility, ensuring special characters don't cause issues with API calls (PR #10054 by @daniel-lxs) +- Improve auto-approve timer visibility in follow-up suggestions for better user awareness of pending actions (PR #10048 by @brunobergher) +- Fix: Cancel auto-approval timeout when user starts typing, preventing accidental auto-approvals during user interaction (PR #9937 by @roomote) +- Add WorkspaceTaskVisibility type for organization cloud settings to support team visibility controls (PR #10020 by @roomote) +- Fix: Extract raw error message from OpenRouter metadata for clearer error reporting (PR #10039 by @daniel-lxs) +- Fix: Show tool protocol dropdown for LiteLLM provider, restoring missing configuration option (PR #10053 by @daniel-lxs) + +## [3.36.5] - 2025-12-11 + +![3.36.5 Release - GPT-5.2](/releases/3.36.5-release.png) + +- Add: GPT-5.2 model to openai-native provider (PR #10024 by @hannesrudolph) +- Add: Toggle for Enter key behavior in chat input allowing users to configure whether Enter sends or creates new line (#8555 by @lmtr0, PR #10002 by @hannesrudolph) +- Add: App version to telemetry exception captures and filter 402 errors (PR #9996 by @daniel-lxs) +- Fix: Handle empty Gemini responses and reasoning loops to prevent infinite retries (PR #10007 by @hannesrudolph) +- Fix: Add missing tool_result blocks to prevent API errors when tool results are expected (PR #10015 by @daniel-lxs) +- Fix: Filter orphaned tool_results when more results than tool_uses to prevent message validation errors (PR #10027 by @daniel-lxs) +- Fix: Add general API endpoints for Z.ai provider (#9879 by @richtong, PR #9894 by @roomote) +- Fix: Apply versioned settings on nightly builds (PR #9997 by @hannesrudolph) +- Remove: Glama provider (PR #9801 by @hannesrudolph) +- Remove: Deprecated list_code_definition_names tool (PR #10005 by @hannesrudolph) + +## [3.36.4] - 2025-12-10 + +![3.36.4 Release - Error Details Modal](/releases/3.36.4-release.png) + +- Add error details modal with on-demand display for improved error visibility when debugging issues (PR #9985 by @roomote) +- Fix: Prevent premature rawChunkTracker clearing for MCP tools, improving reliability of MCP tool streaming (PR #9993 by @daniel-lxs) +- Fix: Filter out 429 rate limit errors from API error telemetry for cleaner metrics (PR #9987 by @daniel-lxs) +- Fix: Correct TODO list display order in chat view to show items in proper sequence (PR #9991 by @roomote) + +## [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) @@ -111,7 +427,7 @@ ## [3.33.0] - 2025-11-18 -![v3.33.0 Release - Twin Kangaroos and the Gemini Constellation](/releases/v3.33.0-release.png) +![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) @@ -374,7 +690,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 @@ -706,7 +1022,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 @@ -1071,7 +1387,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!) @@ -1085,7 +1401,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 @@ -1709,7 +2025,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 @@ -1958,12 +2274,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!) @@ -2101,7 +2417,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 @@ -2118,7 +2434,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 @@ -2302,7 +2618,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/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 446582a5d7..9ba2c98c2c 100644 --- a/apps/web-evals/package.json +++ b/apps/web-evals/package.json @@ -29,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.8", "next-themes": "^0.4.6", "p-map": "^7.0.3", "react": "^18.3.1", @@ -52,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 82a7ebfcbe..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,15 +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") -export async function createRun({ suite, exercises = [], timeout, ...values }: CreateRun) { +export async function createRun({ suite, exercises = [], timeout, iterations = 1, ...values }: CreateRun) { const run = await _createRun({ ...values, timeout, @@ -36,15 +43,34 @@ export async function createRun({ suite, exercises = [], timeout, ...values }: C 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 }, + ) } } @@ -97,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/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..8b2760df98 --- /dev/null +++ b/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts @@ -0,0 +1,147 @@ +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 and history files 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 expectedBase = path.resolve(LOG_BASE_PATH) + + // Add the log file + const logFileName = `${safeLanguage}-${safeExercise}.log` + const logFilePath = path.join(logDir, logFileName) + + // Verify the resolved path is within the expected directory (defense in depth) + const resolvedLogPath = path.resolve(logFilePath) + if (resolvedLogPath.startsWith(expectedBase) && fs.existsSync(logFilePath)) { + archive.file(logFilePath, { name: logFileName }) + filesAdded++ + } + + // Add the API conversation history file + // Format: {language}-{exercise}.{iteration}_api_conversation_history.json + const apiHistoryFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_api_conversation_history.json` + const apiHistoryFilePath = path.join(logDir, apiHistoryFileName) + const resolvedApiHistoryPath = path.resolve(apiHistoryFilePath) + if (resolvedApiHistoryPath.startsWith(expectedBase) && fs.existsSync(apiHistoryFilePath)) { + archive.file(apiHistoryFilePath, { name: apiHistoryFileName }) + filesAdded++ + } + + // Add the UI messages file + // Format: {language}-{exercise}.{iteration}_ui_messages.json + const uiMessagesFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_ui_messages.json` + const uiMessagesFilePath = path.join(logDir, uiMessagesFileName) + const resolvedUiMessagesPath = path.resolve(uiMessagesFilePath) + if (resolvedUiMessagesPath.startsWith(expectedBase) && fs.existsSync(uiMessagesFilePath)) { + archive.file(uiMessagesFilePath, { name: uiMessagesFileName }) + 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 a8ff1484fe..badd77741e 100644 --- a/apps/web-evals/src/app/runs/[id]/run.tsx +++ b/apps/web-evals/src/app/runs/[id]/run.tsx @@ -1,12 +1,15 @@ "use client" -import { useMemo } from "react" -import { LoaderCircle } from "lucide-react" +import { useMemo, useState, useCallback, useEffect, Fragment } from "react" +import { toast } from "sonner" +import { LoaderCircle, FileText, Copy, Check, StopCircle, List, Layers } 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, formatToolUsageSuccessRate } from "@/lib/formatters" import { useRunStatus } from "@/hooks/use-run-status" +import { killRun } from "@/actions/runs" import { Table, TableBody, @@ -17,6 +20,20 @@ import { 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" @@ -24,6 +41,9 @@ import { RunStatus } from "./run-status" type TaskMetrics = Pick<_TaskMetrics, "tokensIn" | "tokensOut" | "tokensContext" | "duration" | "cost"> +// Extended Task type with taskMetrics from useRunStatus +type TaskWithMetrics = Task & { taskMetrics: _TaskMetrics | null } + type ToolUsageEntry = { attempts: number; failures: number } type ToolUsage = Record @@ -35,49 +55,429 @@ function getToolAbbreviation(toolName: string): string { .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) + const [groupByStatus, setGroupByStatus] = useState(() => { + // Initialize from localStorage if available (client-side only) + if (typeof window !== "undefined") { + const stored = localStorage.getItem("evals-group-by-status") + return stored === "true" + } + return false + }) + + // Persist groupByStatus to localStorage + useEffect(() => { + localStorage.setItem("evals-group-by-status", String(groupByStatus)) + }, [groupByStatus]) + + // 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 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) + // Helper to calculate duration from database timestamps when streaming duration + // is unavailable (e.g., page was loaded after TaskStarted event was published) + const calculateDurationFromTimestamps = (task: TaskWithMetrics): number => { + if (!task.startedAt) return 0 + const startTime = new Date(task.startedAt).getTime() + const endTime = task.finishedAt ? new Date(task.finishedAt).getTime() : Date.now() + return endTime - startTime + } - if (task.finishedAt && task.taskMetrics) { - metrics[task.id] = task.taskMetrics - } else if (usage) { + tasks?.forEach((task) => { + const streamingUsage = tokenUsage.get(task.id) + const dbMetrics = task.taskMetrics + + // 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) { + // If DB duration is 0 but we have timestamps, calculate from timestamps + const duration = dbMetrics.duration || calculateDurationFromTimestamps(task) + metrics[task.id] = { ...dbMetrics, duration } + } else if (streamingUsage) { + // Fall back to streaming values if DB is empty/stale + // Use streaming duration, or calculate from timestamps if not available + const duration = streamingUsage.duration || calculateDurationFromTimestamps(task) + metrics[task.id] = { + tokensIn: streamingUsage.totalTokensIn, + tokensOut: streamingUsage.totalTokensOut, + tokensContext: streamingUsage.contextTokens, + duration, + cost: streamingUsage.totalCost, + } + } else { + // Task finished but no DB metrics and no streaming data + // (e.g., page loaded after task completed, metrics not persisted) + // Still provide duration calculated from timestamps + metrics[task.id] = { + tokensIn: 0, + tokensOut: 0, + tokensContext: 0, + duration: calculateDurationFromTimestamps(task), + cost: 0, + } + } + } else if (streamingUsage) { + // For running tasks, use streaming values + // Use streaming duration, or calculate from task.startedAt if not available + // (happens when page loads after TaskStarted event was already published) + const duration = streamingUsage.duration || calculateDurationFromTimestamps(task) 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, + cost: streamingUsage.totalCost, + } + } else if (task.startedAt) { + // Task has started (has startedAt in DB) but no streaming data yet + // This can happen when page loads after TaskStarted but before TokenUsageUpdated + metrics[task.id] = { + tokensIn: 0, + tokensOut: 0, + tokensContext: 0, + duration: calculateDurationFromTimestamps(task), + cost: 0, } } }) return metrics - // eslint-disable-next-line react-hooks/exhaustive-deps }, [tasks, tokenUsage, usageUpdatedAt]) + const onViewTaskLog = useCallback( + async (task: Task) => { + // Only allow viewing logs for tasks that have started. + // Note: we treat presence of derived metrics as evidence of a started task, + // since this page may be rendered without streaming `tokenUsage` populated. + const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id) || !!taskMetrics[task.id] + if (!hasStarted) { + 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, taskMetrics], + ) + + // 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 - // Count running tasks exactly like TaskStatus shows spinner: - // - passed is not true and not false (null/undefined) - // - AND has activity (startedAt or tokenUsage) - const running = tasks.filter( - (t) => t.passed !== true && t.passed !== false && (t.startedAt || tokenUsage.get(t.id)), - ).length - const pending = tasks.filter( - (t) => t.passed !== true && t.passed !== false && !t.startedAt && !tokenUsage.get(t.id), - ).length - const total = tasks.length const completed = passed + failed let totalTokensIn = 0 @@ -85,8 +485,8 @@ export function Run({ run }: { run: Run }) { let totalCost = 0 let totalDuration = 0 - // Aggregate tool usage from completed tasks - const toolUsage: ToolUsage = {} + // Aggregate tool usage from all tasks (both finished and running) + const toolUsageAggregate: ToolUsage = {} for (const task of tasks) { const metrics = taskMetrics[task.id] @@ -97,191 +497,562 @@ export function Run({ run }: { run: Run }) { totalDuration += metrics.duration } - // Aggregate tool usage from finished tasks with taskMetrics - if (task.finishedAt && task.taskMetrics?.toolUsage) { - for (const [key, usage] of Object.entries(task.taskMetrics.toolUsage)) { + // 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 (!toolUsage[tool]) { - toolUsage[tool] = { attempts: 0, failures: 0 } + if (!toolUsageAggregate[tool]) { + toolUsageAggregate[tool] = { attempts: 0, failures: 0 } } - toolUsage[tool].attempts += usage.attempts - toolUsage[tool].failures += usage.failures + toolUsageAggregate[tool].attempts += usage.attempts + toolUsageAggregate[tool].failures += usage.failures } } } + const remaining = tasks.length - completed + return { passed, failed, - running, - pending, - total, completed, + remaining, passRate: completed > 0 ? ((passed / completed) * 100).toFixed(1) : null, totalTokensIn, totalTokensOut, totalCost, totalDuration, - toolUsage, + toolUsage: toolUsageAggregate, } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tasks, taskMetrics, tokenUsage, usageUpdatedAt]) + // 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]) + + // Task status categories + type TaskStatusCategory = "failed" | "in_progress" | "passed" | "not_started" + + const getTaskStatusCategory = useCallback( + (task: TaskWithMetrics): TaskStatusCategory => { + if (task.passed === false) return "failed" + if (task.passed === true) return "passed" + // Check streaming data, DB metrics, or startedAt timestamp + const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id) || !!taskMetrics[task.id] + if (hasStarted) return "in_progress" + return "not_started" + }, + [tokenUsage, taskMetrics], + ) + + // Group tasks by status while preserving original index + const groupedTasks = useMemo(() => { + if (!tasks || !groupByStatus) return null + + const groups: Record> = { + failed: [], + in_progress: [], + passed: [], + not_started: [], + } + + tasks.forEach((task, index) => { + const status = getTaskStatusCategory(task) + groups[status].push({ task, originalIndex: index }) + }) + + return groups + }, [tasks, groupByStatus, getTaskStatusCategory]) + + const statusLabels = useMemo( + (): Record => ({ + failed: { label: "Failed", className: "text-red-500", count: groupedTasks?.failed.length ?? 0 }, + in_progress: { + label: "In Progress", + className: "text-yellow-500", + count: groupedTasks?.in_progress.length ?? 0, + }, + passed: { label: "Passed", className: "text-green-500", count: groupedTasks?.passed.length ?? 0 }, + not_started: { + label: "Not Started", + className: "text-muted-foreground", + count: groupedTasks?.not_started.length ?? 0, + }, + }), + [groupedTasks], + ) + + const statusOrder: TaskStatusCategory[] = ["failed", "in_progress", "passed", "not_started"] + + // Helper to render a task row + const renderTaskRow = (task: TaskWithMetrics, originalIndex: number) => { + const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id) || !!taskMetrics[task.id] + return ( + hasStarted && onViewTaskLog(task)}> + + {originalIndex + 1} + + +
+ +
+ + {task.language}/{task.exercise} + {task.iteration > 1 && ( + (#{task.iteration}) + )} + + {hasStarted && ( + + + + + Click to view log + + )} +
+
+
+ {taskMetrics[task.id] ? ( + <> + +
+
{formatTokens(taskMetrics[task.id]!.tokensIn)}
/ +
{formatTokens(taskMetrics[task.id]!.tokensOut)}
+
+
+ + {formatTokens(taskMetrics[task.id]!.tokensContext)} + + {toolColumns.map((toolName) => { + 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)} + + + ) : ( + + )} +
+ ) + } return ( <>
-
-
-
{run.model}
- {run.description &&
{run.description}
} -
- {!run.taskMetricsId && } -
- - {stats && ( -
- {/* Main Stats Row */} -
- {/* Passed/Failed */} -
-
- {stats.passed} - / - {stats.failed} - {stats.running > 0 && ( - ({stats.running}) - )} -
-
Passed / Failed
-
- - {/* Pass Rate */} -
-
{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
-
- - {/* Tool Usage - Inline */} - {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} -
- ) - })} -
- )} -
-
- )} {!tasks ? ( ) : ( - - - - Exercise - Tokens In / Out - Context - Duration - Cost - - - - {tasks.map((task) => ( - - -
- -
- {task.language}/{task.exercise} + <> + {/* View Toggle */} +
+ + + + + + {groupByStatus ? "Show tasks in run order" : "Group tasks by status"} + + +
+
+ + {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 + + + )}
- - - {taskMetrics[task.id] ? ( - <> - -
-
{formatTokens(taskMetrics[task.id]!.tokensIn)}
/ -
{formatTokens(taskMetrics[task.id]!.tokensOut)}
+ {/* Main Stats Row */} +
+ {/* Pass Rate / Fail Rate / Remaining % */} +
+
+ + {stats.completed > 0 + ? `${((stats.passed / stats.completed) * 100).toFixed(1)}%` + : "-"} + + / + + {stats.completed > 0 + ? `${((stats.failed / stats.completed) * 100).toFixed(1)}%` + : "-"} + + / + + {tasks.length > 0 + ? `${((stats.remaining / tasks.length) * 100).toFixed(1)}%` + : "-"} + +
+
+ {stats.passed} + {" / "} + {stats.failed} + {" / "} + {stats.remaining} + {" of "} + {tasks.length} +
- - - {formatTokens(taskMetrics[task.id]!.tokensContext)} - - - {taskMetrics[task.id]!.duration - ? formatDuration(taskMetrics[task.id]!.duration) - : "-"} - - - {formatCurrency(taskMetrics[task.id]!.cost)} - - - ) : ( - - )} + + {/* 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
+
+ + {/* Estimated Time Remaining - only show if run is active and we have data */} + {!run.taskMetricsId && + elapsedTime !== null && + stats.completed > 0 && + stats.remaining > 0 && ( +
+
+ ~ + {formatDuration( + (elapsedTime / stats.completed) * stats.remaining, + )} +
+
+ Est. Remaining +
+
+ )} +
+ + {/* 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} + +
+ ) + })} +
+ )} + + + )} + + # + Exercise + Tokens In / Out + Context + {toolColumns.map((toolName) => ( + + + {getToolAbbreviation(toolName)} + {toolName} + + + ))} + Duration + Cost - ))} - -
+ + + {groupByStatus && groupedTasks + ? // Grouped view + statusOrder.map((status) => { + const group = groupedTasks[status] + if (group.length === 0) return null + const { label, className } = statusLabels[status] + return ( + + + + + {label} ({group.length}) + + + + {group.map(({ task, originalIndex }) => + renderTaskRow(task, originalIndex), + )} + + ) + }) + : // Default order view + tasks.map((task, index) => renderTaskRow(task, index))} + + + )}
+ + {/* 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 3782f29a36..be015ac8ca 100644 --- a/apps/web-evals/src/app/runs/new/new-run.tsx +++ b/apps/web-evals/src/app/runs/new/new-run.tsx @@ -7,7 +7,7 @@ import { useQuery } from "@tanstack/react-query" import { useForm, FormProvider } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { toast } from "sonner" -import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal } from "lucide-react" +import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal, Info, Plus, Minus } from "lucide-react" import { globalSettingsSchema, @@ -30,6 +30,9 @@ import { TIMEOUT_MIN, TIMEOUT_MAX, TIMEOUT_DEFAULT, + ITERATIONS_MIN, + ITERATIONS_MAX, + ITERATIONS_DEFAULT, } from "@/lib/schemas" import { cn } from "@/lib/utils" @@ -61,7 +64,9 @@ import { PopoverTrigger, Slider, Label, - FormDescription, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui" import { SettingsDiff } from "./settings-diff" @@ -72,17 +77,38 @@ type ImportedSettings = { 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 [provider, setModelSource] = useState<"roo" | "openrouter" | "other">("roo") - 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 - // State for imported settings with config selection + // 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 [selectedConfigName, setSelectedConfigName] = useState("") - const [configPopoverOpen, setConfigPopoverOpen] = useState(false) + const [configSelections, setConfigSelections] = useState([ + { id: crypto.randomUUID(), configName: "", popoverOpen: false }, + ]) const openRouter = useOpenRouterModels() const rooCodeCloud = useRooCodeCloudModels() @@ -106,6 +132,7 @@ export function NewRun() { settings: undefined, concurrency: CONCURRENCY_DEFAULT, timeout: TIMEOUT_DEFAULT, + iterations: ITERATIONS_DEFAULT, jobToken: "", }, }) @@ -117,9 +144,9 @@ export function NewRun() { formState: { isSubmitting }, } = form - const [model, suite, settings] = watch(["model", "suite", "settings", "concurrency"]) + const [suite, settings] = watch(["suite", "settings", "concurrency"]) - // Load concurrency and timeout from localStorage on mount + // Load settings from localStorage on mount useEffect(() => { const savedConcurrency = localStorage.getItem("evals-concurrency") if (savedConcurrency) { @@ -135,6 +162,37 @@ export function NewRun() { 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 @@ -178,6 +236,7 @@ export function NewRun() { setSelectedExercises(newSelected) setValue("exercises", newSelected) + localStorage.setItem("evals-exercises", JSON.stringify(newSelected)) }, [getExercisesForLanguage, selectedExercises, setValue], ) @@ -201,40 +260,165 @@ export function NewRun() { [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 (provider === "openrouter") { - values.settings = { - ...(values.settings || {}), - apiProvider: "openrouter", - openRouterModelId: model, - toolProtocol: useNativeToolProtocol ? "native" : "xml", + // Validate jobToken for Roo Code Cloud provider + if (provider === "roo" && !values.jobToken?.trim()) { + toast.error("Roo Code Cloud Token is required") + return + } + + // 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 if (provider === "roo") { - values.settings = { - ...(values.settings || {}), - apiProvider: "roo", - apiModelId: model, - toolProtocol: useNativeToolProtocol ? "native" : "xml", + } else { + // For openrouter/roo, use model selections + for (const selection of modelSelections) { + if (selection.model) { + selectionsToLaunch.push({ model: selection.model }) + } } } - const { id } = await createRun(values) - router.push(`/runs/${id}`) + 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.") } }, - [provider, model, router, useNativeToolProtocol], - ) - - const onSelectModel = useCallback( - (model: string) => { - setValue("model", model) - setModelPopoverOpen(false) - }, - [setValue, setModelPopoverOpen], + [ + provider, + modelSelections, + configSelections, + importedSettings, + router, + useNativeToolProtocol, + commandExecutionTimeout, + terminalShellIntegrationTimeout, + ], ) const onImportSettings = useCallback( @@ -265,9 +449,9 @@ export function NewRun() { currentApiConfigName: providerProfiles.currentApiConfigName, }) - // Default to the current config + // Default to the current config for the first selection const defaultConfigName = providerProfiles.currentApiConfigName - setSelectedConfigName(defaultConfigName) + setConfigSelections([{ id: crypto.randomUUID(), configName: defaultConfigName, popoverOpen: false }]) // Apply the default config const providerSettings = providerProfiles.apiConfigs[defaultConfigName] ?? {} @@ -283,22 +467,6 @@ export function NewRun() { [clearErrors, setValue], ) - const onSelectConfig = useCallback( - (configName: string) => { - if (!importedSettings) { - return - } - - setSelectedConfigName(configName) - setConfigPopoverOpen(false) - - const providerSettings = importedSettings.apiConfigs[configName] ?? {} - setValue("model", getModelId(providerSettings) ?? "") - setValue("settings", { ...EVALS_SETTINGS, ...providerSettings, ...importedSettings.globalSettings }) - }, - [importedSettings, setValue], - ) - return ( <> @@ -314,9 +482,9 @@ export function NewRun() { value={provider} onValueChange={(value) => setModelSource(value as "roo" | "openrouter" | "other")}> + Import Roo Code Cloud OpenRouter - Other @@ -338,121 +506,217 @@ export function NewRun() { onChange={onImportSettings} /> - {importedSettings && Object.keys(importedSettings.apiConfigs).length > 1 && ( -
- - - - - - - - - - No config found. - - {Object.keys(importedSettings.apiConfigs).map( - (configName) => ( - - {configName} - {configName === - importedSettings.currentApiConfigName && ( - - (default) - - )} - 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 && ( )}
) : ( <> - - - - - - - - - No model found. - - {models?.map(({ id, name }) => ( - - {name} - - - ))} - - - - - +
+ {modelSelections.map((selection, index) => ( +
+ toggleModelPopover(selection.id, open)}> + + + + + + + + No model found. + + {models?.map(({ id, name }) => ( + + updateModelSelection( + selection.id, + id, + ) + }> + {name} + + + ))} + + + + + + {index === modelSelections.length - 1 ? ( + + ) : ( + + )} +
+ ))} +
-
- - setUseNativeToolProtocol(checked === true) - } - /> - +
+ +
+ +
)} @@ -468,20 +732,28 @@ export function NewRun() { name="jobToken" render={({ field }) => ( - Roo Code Cloud Token +
+ 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] + +
+
+
- + - - If you have access to the Roo Code Cloud repository then you can generate a - token with: -
- - pnpm --filter @roo-code-cloud/auth production:create-job-token [org] - [timeout] - -
)} /> @@ -495,12 +767,14 @@ export function NewRun() { Exercises
{ setValue("suite", value as "full" | "partial") + localStorage.setItem("evals-suite", value) if (value === "full") { setSelectedExercises([]) setValue("exercises", []) + localStorage.removeItem("evals-exercises") } }}> @@ -537,6 +811,7 @@ export function NewRun() { onValueChange={(value) => { setSelectedExercises(value) setValue("exercises", value) + localStorage.setItem("evals-exercises", JSON.stringify(value)) }} placeholder="Select" variant="inverted" @@ -548,57 +823,153 @@ export function NewRun() { )} /> - ( - - Concurrency - -
- { - field.onChange(value[0]) - localStorage.setItem("evals-concurrency", String(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]) - localStorage.setItem("evals-timeout", String(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 = { defaultSettings: RooCodeSettings diff --git a/apps/web-evals/src/components/home/run.tsx b/apps/web-evals/src/components/home/run.tsx index b37fff4324..379daf48a4 100644 --- a/apps/web-evals/src/components/home/run.tsx +++ b/apps/web-evals/src/components/home/run.tsx @@ -1,12 +1,13 @@ import { useCallback, useState, useRef } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { Ellipsis, ClipboardList, Copy, Check, LoaderCircle, Trash, Settings } from "lucide-react" +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 { deleteRun, updateRunDescription } from "@/actions/runs" import { formatCurrency, formatDateTime, @@ -19,6 +20,10 @@ import { Button, TableCell, TableRow, + Textarea, + Tooltip, + TooltipContent, + TooltipTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -33,24 +38,95 @@ import { AlertDialogTitle, Dialog, DialogContent, + DialogFooter, DialogHeader, DialogTitle, ScrollArea, } from "@/components/ui" +// Tool group type (same as in runs.tsx) +type ToolGroup = { + id: string + name: string + icon: string + tools: string[] +} + type RunProps = { run: EvalsRun taskMetrics: EvalsTaskMetrics | null toolColumns: ToolName[] + toolGroups: ToolGroup[] } -export function Run({ run, taskMetrics, toolColumns }: RunProps) { +export function Run({ run, taskMetrics, toolColumns, toolGroups }: 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 @@ -75,6 +151,62 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) { [router, run.id], ) + // Helper to render a tool group cell + const renderToolGroupCell = (group: ToolGroup) => { + if (!taskMetrics?.toolUsage) { + return - + } + + let totalAttempts = 0 + let totalFailures = 0 + const breakdown: Array<{ tool: string; attempts: number; rate: string }> = [] + + for (const toolName of group.tools) { + 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 }) + } + } + + if (totalAttempts === 0) { + return - + } + + const successRate = ((totalAttempts - totalFailures) / totalAttempts) * 100 + const rateColor = + successRate === 100 ? "text-muted-foreground" : successRate >= 80 ? "text-yellow-500" : "text-red-500" + + return ( + + +
+ {totalAttempts} + {Math.round(successRate)}% +
+
+ +
+
{group.name}
+ {breakdown.map(({ tool, attempts, rate }) => ( +
+ {tool}: + + {attempts} ({rate}) + +
+ ))} +
+
+
+ ) + } + return ( <> @@ -86,9 +218,13 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) { {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 && ( @@ -98,14 +234,28 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) {
)} + {/* Tool Group Columns */} + {toolGroups.map((group) => ( + + {renderToolGroupCell(group)} + + ))} {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)} + {formatToolUsageSuccessRate(usage)}
) : ( - @@ -116,63 +266,107 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) { {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.settings && ( - setShowSettings(true)}> -
- -
View Settings
+ +
Delete
- )} - {run.taskMetricsId && ( - copyRun()} disabled={isPending || copied}> -
- {isPending ? ( - <> - - Copying... - - ) : copied ? ( - <> - - Copied! - - ) : ( - <> - - Copy to Production - - )} -
-
- )} - { - setDeleteRunId(run.id) - setTimeout(() => continueRef.current?.focus(), 0) - }}> -
- -
Delete
-
-
-
-
+ + +
setDeleteRunId(undefined)}> @@ -201,6 +395,39 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) { + + {/* Notes/Description Dialog */} + + + + Run Description + +
+