Compare commits

..

167 Commits

Author SHA1 Message Date
openhands 57d07df83f Implement FE methods for GitHub installations and BitBucket workspaces 2025-07-21 20:05:26 +00:00
rohitvinodmalhotra@gmail.com 8f594310fa add endpoints for installations 2025-07-21 15:54:12 -04:00
rohitvinodmalhotra@gmail.com a41280f3b8 add provider methods for getting installations 2025-07-21 15:50:55 -04:00
rohitvinodmalhotra@gmail.com 385accd267 add bitbucket workspaces 2025-07-21 15:21:22 -04:00
rohitvinodmalhotra@gmail.com 72a462681c add app installation hook 2025-07-21 15:19:19 -04:00
openhands 27caa0b5fd Remove repository filtering by provider for now 2025-07-21 19:08:34 +00:00
openhands e8cabe6def Add provider dropdown to repository selection 2025-07-21 19:04:32 +00:00
rohitvinodmalhotra@gmail.com 8aa1ae712f update mock 2025-07-21 12:30:17 -04:00
rohitvinodmalhotra@gmail.com 993804dd4f fix tests 2025-07-21 12:25:08 -04:00
rohitvinodmalhotra@gmail.com d5aaa8a67a add params to endpoint 2025-07-21 12:19:49 -04:00
rohitvinodmalhotra@gmail.com 9cc7823723 fix type + get bitbucket installations 2025-07-21 11:57:14 -04:00
openhands def357024e Add get_paginated_repos method to BitBucketService 2025-07-21 15:48:52 +00:00
rohitvinodmalhotra@gmail.com d868eb5dee paginate repos for gitlab 2025-07-21 11:35:10 -04:00
rohitvinodmalhotra@gmail.com a88f3744da add method for paginated response 2025-07-21 11:20:45 -04:00
Xingyao Wang acfb01d743 Fix timestamp corruption in EventStream secret replacement (#9793)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-07-21 22:23:52 +08:00
Regis David Souza Mesquita 0daaf21607 Add support for the groq hosted kimi-k2-instruct model in the functio… (#9759) 2025-07-21 15:14:09 +02:00
llamantino 95bda09cd9 fix(settings): improve MCP editor layout and controls (#9771)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-07-20 19:15:45 +00:00
Rohit Malhotra 1243612694 [Fix]: instruct agent to use token when accessing provider apis (#9807) 2025-07-19 22:04:07 +00:00
Graham Neubig aff8aba5e6 Fix: Use correct git provider in Push & Create PR button (#9803)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-19 11:13:29 -04:00
mamoodi aea37e52f7 Update gitlab integration docs (#9025)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-07-18 16:42:56 -04:00
Xingyao Wang f5674d7c76 feat(agent): Add import placement guidance to CodeAct agent system prompts (#9794)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-18 19:59:09 +00:00
Vasi 9c68146b04 feat: [CLI] 9392 cli improve confirmation ux (#9758) 2025-07-18 19:42:17 +00:00
Boxuan Li ee14f1ea41 Remove poetry dependency in Jupyter Plugin (#9789) 2025-07-18 18:54:53 +00:00
Xingyao Wang b96301061d Bump version in pyproject.toml (#9790) 2025-07-18 14:48:37 -04:00
dependabot[bot] 1281f2d6c2 chore(deps): bump @vitejs/plugin-react from 4.6.0 to 4.7.0 in /frontend in the version-all group (#9785)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-18 21:45:40 +04:00
Hiep Le dc41e0e90c feat(backend): Develop an API to fetch conversations by repository and conversation_trigger. (#9764) 2025-07-18 15:44:24 +00:00
Graham Neubig 793786130a Clarify GitHub integration docs regarding @openhands mentions in pull requests (#9314)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-18 10:58:39 -04:00
Hiep Le 59f03122c7 feat(frontend): Build Add Microagent Modal UI (#9735) 2025-07-18 18:28:13 +04:00
Hiep Le 67edc66da7 feat(backend): Support CreateMicroagent in the “Create New Conversation” API (#9765) 2025-07-18 01:31:09 -04:00
Graham Neubig cb910e6863 Fix MCP tool timeout causing agent to stall indefinitely (#9779)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-17 18:53:09 -04:00
mamoodi 4c39e92351 Docs for OpenHands LLM Provider (#9751) 2025-07-17 18:51:34 +00:00
Engel Nyst e65e0a98f0 Remove/reduce unused content in a CmdOutputObservation (#7404)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-17 19:34:46 +02:00
Hiep Le eecc00fa4a feat(backend): API to get the microagents for the selected repository. (#9749) 2025-07-17 21:00:45 +04:00
sp.wack 5654e251a8 chore: bump to 1.0.0-beta.5 (#9770) 2025-07-17 16:44:01 +00:00
Rohit Malhotra d9694aabcd Add conditional rendering of auth providers based on server config (#9752)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-17 16:42:57 +00:00
Ray Myers bc8ef37192 fix - Avoid building debug log message when not logged (#9600) 2025-07-17 11:42:06 -05:00
Ray Myers 5f141f7712 Fix type hint: add | None to first element of create_default_mcp_server_config return tuple (#9754)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-17 12:10:16 -04:00
Hiep Le 30e3011cb0 feat(backend): Include owner_type in the Get Repositories API response. (#9763) 2025-07-17 11:45:05 -04:00
Xingyao Wang 3475d8021b Fix file duplication in system prompt (#9741)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-17 15:29:44 +00:00
dependabot[bot] 32cd50db2f chore(deps): bump the version-all group in /frontend with 6 updates (#9762)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-17 15:13:44 +00:00
Graham Neubig f0a6db936c Fix: Add navigation to conversation page after clicking Launch button on task suggestions (#9760)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-17 17:43:13 +04:00
Peter Hamilton 11c37d8d70 Update llm constants to match on unpinned claude-sonnet-4 (#9681) 2025-07-17 13:48:35 +02:00
Hiep Le 7e1367057a feat(frontend): Build Microagent Management Sidebar UI. (#9717) 2025-07-17 15:45:24 +04:00
dependabot[bot] 3bbb0c6279 chore(deps): bump the version-all group in /frontend with 2 updates (#9739)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-17 11:40:08 +00:00
Xingyao Wang eed71c21bd Add kimi-k2-0711-preview model to OpenHands provider (#9755)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-17 15:25:31 +04:00
Graham Neubig 4f46826de9 Add Moonshot AI Kimi-K2 model to recommended models (#9706)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-07-17 04:43:03 +00:00
juanmichelini ea50fe4e3c Fix: Continue evaluation when an instance fails after max retries (#8868)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyaoww@gmail.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-07-16 22:42:44 +00:00
Tim O'Farrell b057af8d63 Feat: Add current working directory to LLM instructions (#9718) 2025-07-16 21:10:03 +00:00
Engel Nyst fba2218760 Fix integration tests (#9746) 2025-07-16 22:16:40 +02:00
mamoodi 6147cbdc18 Update OpenHands Cloud with Bitbucket docs (#9740) 2025-07-16 15:10:12 -04:00
Mislav Lukach 802acb3c7e feat(ui): select component (#9712)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-07-16 17:28:01 +00:00
Xingyao Wang 376dc21e34 (llm): Add Kimi K2 to function calling supported model (#9747) 2025-07-16 17:19:10 +00:00
Mislav Lukach 387318385c feat(ui): tab component (#9673)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-07-16 16:38:51 +00:00
Mislav Lukach 553f0a0918 feat(ui): toast component (#9632)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-07-16 16:33:31 +00:00
mamoodi 0d1e21ae45 Release 0.49.0 (#9691)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-07-16 08:46:41 -04:00
Xingyao Wang a885e9e4d2 Fix newline display in frontend UI (#9729) 2025-07-15 20:59:56 -04:00
Graham Neubig 4c10848e8d Fix dictionary changed size during iteration error in override_provider_tokens_with_custom_secret (#9728)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-15 19:03:28 -04:00
Tim O'Farrell 1d95b01514 Fix: Keep the existing behavior in the docker command. (#9724) 2025-07-15 19:34:00 +00:00
Xingyao Wang cd32b5508c Add OpenAI o3 model support to verified models and OpenHands provider (#9720)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-15 18:19:44 +00:00
Xingyao Wang 9a3bf0f2aa chore(cli): make sonnet first in openhands provider model choice (#9719) 2025-07-15 17:38:08 +00:00
Ryan H. Tran 1d04a83e08 docs: Add SHTTP transport documentation to MCP usage guide (#9701)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-15 23:18:05 +07:00
Hiep Le 17e9b0fd6a chore(Microagent Management UI): Set up the feature flag for the Microagent Management page. (#9704) 2025-07-15 19:49:35 +04:00
dependabot[bot] 54986c9841 chore(deps): bump the version-all group in /frontend with 3 updates (#9709)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-15 14:50:20 +00:00
Xingyao Wang c419277326 Fix Likert Scale displaying "Star Rating" text instead of star icons (#9708)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-15 22:38:02 +08:00
Hiep Le 35b945b9d1 refactor(frontend): Display TOS and Privacy policy links on Sign In page (#9697)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-07-15 14:11:51 +00:00
Boxuan Li 5c3619bc48 Add README for terminal_bench evaluation harness (#9700) 2025-07-15 09:48:34 -04:00
Tim O'Farrell 641d0a0bcb Set vscode to use the correct workspace directory (#9698) 2025-07-14 17:40:32 -06:00
dependabot[bot] fbadea9a6f chore(deps): bump the version-all group in /frontend with 3 updates (#9696)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-14 22:34:26 +04:00
Xingyao Wang 6e25d4bbb6 Add OpenHands provider for LLM through OH Cloud (#9526)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-15 01:44:49 +08:00
sp.wack 127220dc39 chore(ui): npm package config (#9535)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2025-07-14 20:50:44 +04:00
Tim O'Farrell 9a291e385b Introduced config field to determine whether to init a git repo (#9693) 2025-07-14 10:17:26 -06:00
Tim O'Farrell 95ccec82d9 refactor: make /events endpoint lightweight without requiring active conversation (#9685)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-13 17:14:15 -06:00
Xingyao Wang 4aaa2ccd39 Add CLI alias setup for first-time users (#9542)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-13 15:36:06 +00:00
Tim O'Farrell bfe0aa08b6 Fix issue where user gets stuck on TOS page (#9676) 2025-07-11 19:28:13 -06:00
Tim O'Farrell 7fb47761c6 Fix: VSCode using Temp Directory in Nested Mode (#9672) 2025-07-11 18:53:05 +00:00
Xuhui Zhou 415931b4dc Update system prompt for interactional system (#9284)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-11 18:07:56 +00:00
Hiep Le 6d57eeb3ed feat: Allow the users to edit the conversation's title. (#9648) 2025-07-11 21:46:51 +04:00
Hiep Le c03d390772 fix(frontend): The conversation page cannot be used on mobile devices and tablets. (#9558) 2025-07-11 21:43:53 +04:00
dependabot[bot] a266d4274a chore(deps): bump the version-all group in /frontend with 3 updates (#9669)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-11 21:20:21 +04:00
Engel Nyst a19cd193d9 Log vscode error in a visible way (#9668)
Co-authored-by: OpenHands Bot <openhands@all-hands.dev>
2025-07-11 15:41:21 +00:00
Ivan Dagelic 4f3e648379 chore: update daytona sdk and proxy endpoint (#9664)
Signed-off-by: Ivan Dagelic <dagelic.ivan@gmail.com>
2025-07-11 17:33:12 +02:00
Tim O'Farrell b99150c616 Fixes or vscode token / url not being present (#9661) 2025-07-11 09:31:39 -06:00
OpenHands 8937b3fbfc Fix issue #9655: [Bug]: CodeActAgent is incompatible with xAI Grok-4 due to hardcoded stop parameter (#9666)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-11 15:31:11 +00:00
juanmichelini fb5a39a150 Fix libgl1 package for mswebench base images (#9071) 2025-07-11 10:30:33 -05:00
sp.wack fc11c15b75 hotfix(ui): Agent message that includes codeblocks overflows (#9667) 2025-07-11 14:35:55 +00:00
Engel Nyst 50a8741d50 Build from vsix first (#9656)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-11 04:53:06 +02:00
xhguo7 9388fef0ef feat(eval): loc acc evaluation (#8515)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2025-07-11 03:22:35 +08:00
Tim O'Farrell 050e80cc34 Add warm server functionality to local runtime (#9033)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-10 12:12:39 -06:00
Ray Myers 5cc47ee592 Optimize dockerfile by consolidating and reordering steps (#9549) 2025-07-10 12:20:36 -05:00
dependabot[bot] a09346672f chore(deps): bump the version-all group in /frontend with 3 updates (#9651)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-10 19:09:44 +04:00
ManOwnFire 9e72b69cf8 fix (cli): issue 9386 - show settings.json path in /settings (#9481) 2025-07-10 14:59:06 +00:00
sp.wack da1f3a5a7b chore(frontend): Ugprade Node requirement to v22 LTS (#9639) 2025-07-10 17:21:03 +04:00
Hiep Le 5c27a452ac refactor(frontend): Make the API keys table styling consistent. (#9630) 2025-07-10 16:07:35 +04:00
Hiep Le 8cb1c738ff refactor(frontend): Make the secrets table styling consistent. (#9628) 2025-07-10 16:05:24 +04:00
Tim O'Farrell cf276b2e96 All Runtime Status Codes should be in the RuntimeStatus enum (#9601)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-09 15:34:46 -06:00
sp.wack 1f416f616c chore(ui): Fix late redirects in settings page (#9596)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-09 17:26:54 +00:00
sp.wack 52775acd4d chore(eslint): Extend eslint rules to error on i18next/on no-literal-string (#9616)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-09 20:30:24 +04:00
Engel Nyst be0596abd6 add log-level (#9637) 2025-07-09 11:19:10 -04:00
dependabot[bot] e77957aa92 chore(deps): bump the version-all group in /frontend with 3 updates (#9635)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-09 18:57:28 +04:00
Eleanor Berger d04c4c493e Update OpenAI model selection for better agentic coding support (#9597) 2025-07-09 14:44:02 +00:00
Mislav Lukach 5cb534217a feat(ui): spinner component (#9590) 2025-07-09 18:42:29 +04:00
Tim O'Farrell 9331f5e8a7 Fixes for docker nested runtime (#9634) 2025-07-09 08:39:42 -06:00
Hiep Le 8d16567428 refactor(frontend): The Jupyter tab is not showing "Waiting for runtime to start..." when connecting to an agent (#9626) 2025-07-09 18:33:09 +04:00
Xingyao Wang acc69b74c5 docs: Add CLI installation options with shell aliases and local installation (#9575)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-09 03:42:24 +08:00
mamoodi 28d174a7ce Small documentation updates (#9622)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-08 15:33:22 -04:00
Xingyao Wang cff5697456 eval: remove gemini-specific swebench template (#9623) 2025-07-08 18:34:23 +00:00
sp.wack 794eedf503 feat(frontend): Memory UI (#8592)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Robert Brennan <accounts@rbren.io>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-07-08 16:24:07 +00:00
Hiep Le a6ffb2f799 refactor(frontend): Remove the border bottom of the last element on the suggested tasks. (#9610) 2025-07-08 19:13:51 +04:00
Mislav Lukach 3be3779f68 feat(ui): dialog component (#9591) 2025-07-08 19:06:46 +04:00
sp.wack 222f5fdd51 chore: Update codeowners (#9619) 2025-07-08 15:01:00 +00:00
Mislav Lukach 2066f90654 feat(ui): accordion component (#9537) 2025-07-08 18:57:31 +04:00
dependabot[bot] 9ee2f976a1 chore(deps): bump vite from 7.0.2 to 7.0.3 in /frontend in the version-all group (#9618)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-08 14:54:36 +00:00
Hiep Le be62df5277 fix(frontend): Jupyter tab requires page refresh to display content (#9614) 2025-07-08 18:30:58 +04:00
Hiep Le 4baf2a64c1 refactor(frontend): Show the git providers on the suggested tasks (#9608) 2025-07-08 18:25:09 +04:00
Hiep Le 2a833325e1 fix(frontend): The suggested tasks section only filters the tasks by the repository’s title. (#9606) 2025-07-08 18:24:30 +04:00
Hiep Le aa2cacab44 fix(frontend): The terminal is still shown when connecting to an agent. (#9603) 2025-07-08 18:21:06 +04:00
tangwei12 ea07570f62 fix openhands cli loglevel (#9382)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-08 16:07:13 +02:00
Kenny Dizi 3f5a5005a2 Improve configuration for reasoning_effort (#9572) 2025-07-08 10:05:15 -04:00
mindflow-cn 7acee9e5da Allow workspace_mount_path to use relative paths (#9615)
Co-authored-by: jianchuanli <jianchuanli@langcode.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-07-08 21:47:28 +08:00
mamoodi 37cbeb735f Some documentation update (#9598)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-08 08:59:08 -04:00
Graham Neubig c6c6c202f6 Fix CLI thought display order issue (#9417)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 23:33:57 +02:00
Tim O'Farrell 517a72fd0d Use the same event stream instance for conversations as sessions (#9545)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 14:37:17 -06:00
Xingyao Wang 7cfecb6e52 Increase success toast duration to 5 seconds with dynamic calculation (#9574)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-08 02:33:47 +08:00
Tim O'Farrell 8fe2e006ee Added run_in_loop method (#9586)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 11:01:17 -06:00
dependabot[bot] 6d62c341eb chore(deps): bump @heroui/react from 2.8.0-beta.11 to 2.8.0-beta.13 in /frontend in the version-all group (#9587)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 16:48:59 +00:00
Tim O'Farrell 229f35093d perf: make EventStore cur_id a lazy calculated property (#9544)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 09:58:46 -06:00
Rohit Malhotra 21a5e3eed5 Improve error logging in verify_repo_provider before AuthenticationError (#9530)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 10:02:46 -05:00
Hiep Le 97e3310dd5 fix(frontend): the GET microagents API is called multiple times, and the Available Microagents modal loads for an extended period if the conversation is connecting to an agent. (#9517) 2025-07-07 18:08:26 +04:00
Hiep Le 2053e72474 fix(frontend): Not able to scroll the chat input after pasting long content or clicking on a suggested action. (#9550) 2025-07-07 18:06:45 +04:00
dependabot[bot] 300f20368e chore(deps): bump the version-all group in /frontend with 4 updates (#9559)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 17:52:34 +04:00
Hiep Le 0bed046fcc fix(frontend): NaN is shown on the conversation card (#9581) 2025-07-07 17:51:56 +04:00
Hiep Le 0bf0dc9316 fix(frontend): [OpenHands Cloud] The Delete API Key modal does not display correctly if the API key name is too long. (#9556) 2025-07-07 17:50:11 +04:00
Hiep Le 0e8d9a8bb4 fix(frontend): [OpenHands Cloud] The API keys table does not display properly if the API key name is too long. (#9554) 2025-07-07 17:49:13 +04:00
Robert Brennan 9280bc34ad Enhance logging for org-level microagent loading to improve debugging (#9471)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 12:31:45 +02:00
Graham Neubig b132348d22 Fix Jupyter tab not showing input commands (#9533)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 09:43:43 +02:00
TakumaNakao 1be77faf94 ADD gemini-2.5 to REASONING_EFFORT_SUPPORTED_MODELS (#9546) 2025-07-06 06:31:41 +00:00
Boxuan Li a6301075ec Add core config to disable browser environment (#9570) 2025-07-06 08:20:58 +02:00
Boxuan Li b98615bc1c Mark memory-profiler & jupyter_kernel_gateway dependency as non-optional (#9562) 2025-07-06 04:13:21 +02:00
Boxuan Li 29fdc701a3 Jupyter: remove poetry dependency (#9561) 2025-07-05 15:03:26 -07:00
Ryan H. Tran 8bc9207c24 Add instruction to use Arctic Inference (#9547) 2025-07-04 20:34:05 +07:00
Hiep Le 96008736a4 fix(frontend): [OpenHands Cloud] The input outline on the user setting page is not consistent. (#9552) 2025-07-04 13:23:03 +04:00
Robert Brennan 38d5db0547 Fix capitalization in 'Initializing agent...' status message (#9406)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-04 09:56:40 +02:00
Robert Brennan 8af1f1cac9 Add labels support to PR and MR creation tools (#9402)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-04 09:55:54 +02:00
Engel Nyst ef502ccba8 Add CLI/vscode integration (#9085)
Co-authored-by: OpenHands-Gemini <openhands@all-hands.dev>
Co-authored-by: Claude 3.5 Sonnet <claude-3-5-sonnet@anthropic.com>
2025-07-03 22:42:06 +02:00
Tim O'Farrell ece556c047 Fix issue where coro was not awaited (#9536) 2025-07-03 13:32:51 -06:00
Tim O'Farrell 55a09785ce Fix for issue where wrong method was called (#9532) 2025-07-03 11:46:48 -06:00
Mislav Lukach 2990c21d97 fix(ui): fix base components styling (#9528) 2025-07-03 21:21:18 +04:00
dependabot[bot] 14c8ea93c9 chore(deps): bump vite from 7.0.0 to 7.0.1 in /frontend in the version-all group (#9529)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 21:20:22 +04:00
Mislav Lukach 764077ef3d Feat/create UI dir (#9462)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-07-03 13:26:19 +00:00
Hiep Le 63ead2a638 fix(frontend): The "available microagents" modal does not show the latest agents after adding a new agent or updating the current agents (#9502) 2025-07-03 13:11:06 +00:00
Hiep Le be0049c76e fix(frontend): Some strings are not included in the translation file. (#9524) 2025-07-03 12:55:13 +00:00
Hiep Le bafd1596dd fix(frontend): The secret settings layout will be broken if the secret name is too long. (#9522) 2025-07-03 12:54:47 +00:00
Hiep Le ce58ccab8a fix(frontend): Changing languages on the settings page does not work for some languages. (#9515) 2025-07-03 16:35:52 +04:00
sp.wack b3c8b7c089 Fix WebSocket disconnection when uploading large files (#9504)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-03 16:28:30 +04:00
Engel Nyst ac2947b7ff Fix /init on CLI Runtime (#9474)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-03 08:28:18 -04:00
mamoodi 91cd647f20 Add item to troubleshooting guide (#9490) 2025-07-02 16:31:26 -04:00
mamoodi c521fb7a8f Release 0.48.0 (#9491) 2025-07-02 16:21:45 -04:00
Rohit Malhotra f049411631 (Hotfix): Microagent won't load depending on version number format (#9508)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-02 18:06:36 +00:00
Tim O'Farrell 606ec59b33 Fix CLI confirmation input to handle invalid input properly (#9503)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-02 10:48:43 -06:00
Graham Neubig d2fc5679ad Improve rate limit message to indicate automatic retry (#9281)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-02 12:27:35 -04:00
Hiep Le 7bfa05d38a refactor(frontend): Show branch name and git provider on the conversation cards (#9480)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-07-02 16:04:55 +00:00
dependabot[bot] 12a95fb548 chore(deps): bump the version-all group in /frontend with 7 updates (#9506)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 15:08:05 +00:00
llamantino ae03c4eb80 chore: bump openhands-aci to 0.3.1 to fix ffmpeg warning (#9500) 2025-07-02 13:49:51 +00:00
mindflow-cn 8e486dfd6b Replace libtmux's deprecated methods in bash.py (#9463)
Co-authored-by: jianchuanli <jianchuanli@langcode.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-07-01 21:07:48 -04:00
Rohit Malhotra 48ee5659c9 Conditionally render 'Add GitHub repos' link based on provider (#9499)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-01 20:56:00 -04:00
Graham Neubig b7613d7529 Fix feedback endpoint calls in OSS mode (#9476)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-01 16:31:05 -04:00
Graham Neubig e05e627957 Add ArcticInference doc (#9492) 2025-07-01 14:15:13 -04:00
414 changed files with 34239 additions and 4300 deletions
+1
View File
@@ -3,6 +3,7 @@
# Frontend code owners
/frontend/ @rbren @amanape
/openhands-ui/ @amanape
# Evaluation code owners
/evaluation/ @xingyaoww @neubig
+4 -4
View File
@@ -9,8 +9,8 @@ on:
- main
pull_request:
paths:
- 'frontend/**'
- '.github/workflows/fe-unit-tests.yml'
- "frontend/**"
- ".github/workflows/fe-unit-tests.yml"
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
@@ -24,7 +24,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [20, 22]
node-version: 22
fail-fast: true
steps:
- name: Checkout
@@ -38,7 +38,7 @@ jobs:
run: npm ci
- name: Run TypeScript compilation
working-directory: ./frontend
run: npm run make-i18n && tsc
run: npm run build
- name: Run tests and collect coverage
working-directory: ./frontend
run: npm run test:coverage
+19 -111
View File
@@ -40,7 +40,8 @@ jobs:
# Only build nikolaik on PRs, otherwise build both nikolaik and ubuntu.
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
else
json=$(jq -n -c '[
@@ -54,12 +55,10 @@ jobs:
ghcr_build_app:
name: Build App Image
runs-on: blacksmith-4vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
outputs:
# Since this job uses outputs it cannot use matrix
hash_from_app_image: ${{ steps.get_hash_in_app_image.outputs.hash_from_app_image }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -85,24 +84,12 @@ jobs:
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
- name: Build app image
if: "github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --load
- name: Get hash in App Image
id: get_hash_in_app_image
run: |
# Run the build script in the app image
docker run -e SANDBOX_USER_ID=0 -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/${{ env.REPO_OWNER }}/openhands:${{ env.RELEVANT_SHA }} /bin/bash -c "mkdir -p containers/runtime; python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild" 2>&1 | tee docker-outputs.txt
# Get the hash from the build script
hash_from_app_image=$(cat docker-outputs.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
echo "hash_from_app_image=$hash_from_app_image" >> $GITHUB_OUTPUT
echo "Hash from app image: $hash_from_app_image"
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Image
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: blacksmith-8vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
@@ -128,22 +115,13 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
# This is the one that saves the cache, the others set 'lookup-only: true'
restore-keys: |
${{ runner.os }}-poetry-
- name: Install poetry via pipx
run: pipx install poetry
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Create source distribution and Dockerfile
@@ -188,61 +166,6 @@ jobs:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
verify_hash_equivalence_in_runtime_and_app:
name: Verify Hash Equivalence in Runtime and Docker images
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [ghcr_build_runtime, ghcr_build_app]
strategy:
fail-fast: false
matrix:
base_image: ['nikolaik']
env:
BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST: nikolaik/python-nodejs:python3.12-nodejs22
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
- name: Install poetry via pipx
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Get hash in App Image
run: |
echo "Hash from app image: ${{ needs.ghcr_build_app.outputs.hash_from_app_image }}"
echo "hash_from_app_image=${{ needs.ghcr_build_app.outputs.hash_from_app_image }}" >> $GITHUB_ENV
- name: Get hash using code (development mode)
run: |
mkdir -p containers/runtime
poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild > output.txt 2>&1
hash_from_code=$(cat output.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
echo "hash_from_code=$hash_from_code" >> $GITHUB_ENV
- name: Compare hashes
run: |
echo "Hash from App Image: ${{ env.hash_from_app_image }}"
echo "Hash from Code: ${{ env.hash_from_code }}"
if [ "${{ env.hash_from_app_image }}" = "${{ env.hash_from_code }}" ]; then
echo "Hashes match!"
else
echo "Hashes do not match!"
exit 1
fi
# Run unit tests with the Docker runtime Docker images as root
test_runtime_root:
name: RT Unit Tests (Root)
@@ -274,25 +197,17 @@ jobs:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
- name: Install poetry via pipx
run: pipx install poetry
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Run docker runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
@@ -344,25 +259,17 @@ jobs:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
- name: Install poetry via pipx
run: pipx install poetry
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
- name: Run runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
@@ -389,7 +296,7 @@ jobs:
name: All Runtime Tests Passed
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
needs: [test_runtime_root, test_runtime_oh]
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
@@ -398,7 +305,7 @@ jobs:
name: All Runtime Tests Passed
if: ${{ cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
needs: [test_runtime_root, test_runtime_oh]
steps:
- name: Some tests failed
run: |
@@ -423,6 +330,7 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
SHORT_SHA: ${{ steps.short_sha.outputs.SHORT_SHA }}
shell: bash
run: |
echo "updating PR description"
DOCKER_RUN_COMMAND="docker run -it --rm \
+3 -3
View File
@@ -54,7 +54,7 @@ jobs:
Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime
run: poetry install --with dev,test,runtime,evaluation
- name: Configure config.toml for testing with Haiku
env:
@@ -179,8 +179,8 @@ jobs:
id: create_comment
uses: KeisukeYamashita/create-comment@v1
with:
# if triggered by PR, use PR number, otherwise use 5318 as fallback issue number for manual triggers
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 5318 }}
# if triggered by PR, use PR number, otherwise use 9745 as fallback issue number for manual triggers
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 9745 }}
unique: false
comment: |
Trigger by: ${{ github.event_name == 'pull_request' && format('Pull Request (integration-test label on PR #{0})', github.event.pull_request.number) || (github.event_name == 'workflow_dispatch' && format('Manual Trigger: {0}', github.event.inputs.reason)) || 'Nightly Scheduled Run' }}
+3 -3
View File
@@ -21,10 +21,10 @@ jobs:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Node.js 20
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
with:
node-version: 20
node-version: 22
- name: Install frontend dependencies
run: |
cd frontend
@@ -68,7 +68,7 @@ jobs:
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: 'pip'
cache: "pip"
- name: Install pre-commit
run: pip install pre-commit==3.7.0
- name: Fix python lint issues
+4 -4
View File
@@ -7,7 +7,7 @@ name: Lint
on:
push:
branches:
- main
- main
pull_request:
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
@@ -22,10 +22,10 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- name: Install Node.js 20
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
with:
node-version: 20
node-version: 22
- name: Install dependencies
run: |
cd frontend
@@ -49,7 +49,7 @@ jobs:
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: 'pip'
cache: "pip"
- name: Install pre-commit
run: pip install pre-commit==3.7.0
- name: Run pre-commit hooks
+108
View File
@@ -0,0 +1,108 @@
name: Publish OpenHands UI Package
# * Always run on "main"
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
on:
push:
branches:
- main
paths:
- "openhands-ui/**"
- ".github/workflows/npm-publish-ui.yml"
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: npm-publish-ui
cancel-in-progress: false
jobs:
check-version:
name: Check if version has changed
runs-on: blacksmith-4vcpu-ubuntu-2204
defaults:
run:
shell: bash
outputs:
should-publish: ${{ steps.version-check.outputs.should-publish }}
current-version: ${{ steps.version-check.outputs.current-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need previous commit to compare
- name: Check if version changed
id: version-check
run: |
# Get current version from package.json
CURRENT_VERSION=$(jq -r .version openhands-ui/package.json)
echo "current-version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
# Check if package.json version changed in this commit
if git diff HEAD~1 HEAD --name-only | grep -q "openhands-ui/package.json"; then
# Check if the version field specifically changed
if git diff HEAD~1 HEAD openhands-ui/package.json | grep -q '"version"'; then
echo "Version changed in package.json, will publish"
echo "should-publish=true" >> $GITHUB_OUTPUT
else
echo "package.json changed but version did not change, skipping publish"
echo "should-publish=false" >> $GITHUB_OUTPUT
fi
else
echo "package.json did not change, skipping publish"
echo "should-publish=false" >> $GITHUB_OUTPUT
fi
publish:
name: Publish to npm
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"
- name: Install dependencies
working-directory: ./openhands-ui
run: bun install --frozen-lockfile
- name: Build package
working-directory: ./openhands-ui
run: bun run build
- name: Check if package already exists on npm
id: npm-check
working-directory: ./openhands-ui
run: |
PACKAGE_NAME=$(jq -r .name package.json)
VERSION="${{ needs.check-version.outputs.current-version }}"
# Check if this version already exists on npm
if npm view "$PACKAGE_NAME@$VERSION" version 2>/dev/null; then
echo "Version $VERSION already exists on npm, skipping publish"
echo "already-exists=true" >> $GITHUB_OUTPUT
else
echo "Version $VERSION does not exist on npm, proceeding with publish"
echo "already-exists=false" >> $GITHUB_OUTPUT
fi
- name: Setup npm authentication
if: steps.npm-check.outputs.already-exists == 'false'
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Publish to npm
if: steps.npm-check.outputs.already-exists == 'false'
working-directory: ./openhands-ui
run: |
# The prepublishOnly script will run automatically and build the package
npm publish
echo "✅ Successfully published @openhands/ui@${{ needs.check-version.outputs.current-version }} to npm"
@@ -1,5 +1,5 @@
# Workflow that runs python unit tests
name: Run Python Unit Tests
# Workflow that runs python tests
name: Run Python Tests
# The jobs in this workflow are required, so they must run at all times
# * Always run on "main"
@@ -16,9 +16,9 @@ concurrency:
cancel-in-progress: true
jobs:
# Run python unit tests on Linux
# Run python tests on Linux
test-on-linux:
name: Python Unit Tests on Linux
name: Python Tests on Linux
runs-on: blacksmith-4vcpu-ubuntu-2204
env:
INSTALL_DOCKER: '0' # Set to '0' to skip Docker installation
@@ -51,6 +51,8 @@ jobs:
run: poetry run pytest --forked -n auto -svv ./tests/unit
- name: Run Runtime Tests with CLIRuntime
run: TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
- name: Run E2E Tests
run: poetry run pytest -svv tests/e2e
# Run specific Windows python tests
test-on-windows:
+34
View File
@@ -0,0 +1,34 @@
name: Run UI Component Build
# * Always run on "main"
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
on:
push:
branches:
- main
pull_request:
paths:
- 'openhands-ui/**'
- '.github/workflows/ui-build.yml'
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
ui-build:
name: Build openhands-ui
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"
- name: Install dependencies
working-directory: ./openhands-ui
run: bun install --frozen-lockfile
- name: Build package
working-directory: ./openhands-ui
run: bun run build
@@ -0,0 +1,156 @@
# Workflow that validates the VSCode extension builds correctly
name: VSCode Extension CI
# * Always run on "main"
# * Run on PRs that have changes in the VSCode extension folder or this workflow
# * Run on tags that start with "ext-v"
on:
push:
branches:
- main
tags:
- 'ext-v*'
pull_request:
paths:
- 'openhands/integrations/vscode/**'
- 'build_vscode.py'
- '.github/workflows/vscode-extension-build.yml'
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
# Validate VSCode extension builds correctly
validate-vscode-extension:
name: Validate VSCode Extension Build
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
with:
node-version: '22'
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install VSCode extension dependencies
working-directory: ./openhands/integrations/vscode
run: npm ci
- name: Build VSCode extension via build_vscode.py
run: python build_vscode.py
env:
# Ensure we don't skip the build
SKIP_VSCODE_BUILD: ""
- name: Validate .vsix file
run: |
# Verify the .vsix was created and is valid
if [ -f "openhands/integrations/vscode/openhands-vscode-0.0.1.vsix" ]; then
echo "✅ VSCode extension built successfully"
ls -la openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
# Basic validation that the .vsix is a valid zip file
echo "🔍 Validating .vsix structure..."
file openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
unzip -t openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
echo "✅ VSCode extension validation passed"
else
echo "❌ VSCode extension build failed - .vsix not found"
exit 1
fi
- name: Upload VSCode extension artifact
uses: actions/upload-artifact@v4
with:
name: vscode-extension
path: openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
retention-days: 7
- name: Comment on PR with artifact link
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
// Get file size for display
const vsixPath = 'openhands/integrations/vscode/openhands-vscode-0.0.1.vsix';
const stats = fs.statSync(vsixPath);
const fileSizeKB = Math.round(stats.size / 1024);
const comment = `## 🔧 VSCode Extension Built Successfully!
The VSCode extension has been built and is ready for testing.
**📦 Download**: [openhands-vscode-0.0.1.vsix](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (${fileSizeKB} KB)
**🚀 To install**:
1. Download the artifact from the workflow run above
2. In VSCode: \`Ctrl+Shift+P\` → "Extensions: Install from VSIX..."
3. Select the downloaded \`.vsix\` file
**✅ Tested with**: Node.js 22
**🔍 Validation**: File structure and integrity verified
---
*Built from commit ${{ github.sha }}*`;
// Check if we already commented on this PR and delete it
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('VSCode Extension Built Successfully')
);
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
}
// Create a new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
release:
name: Create GitHub Release
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: validate-vscode-extension
if: startsWith(github.ref, 'refs/tags/ext-v')
steps:
- name: Download .vsix artifact
uses: actions/download-artifact@v4
with:
name: vscode-extension
path: ./
- name: Create Release
uses: ncipollo/release-action@v1.16.0
with:
artifacts: "*.vsix"
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
allowUpdates: true
+2
View File
@@ -182,6 +182,8 @@ cython_debug/
.roo/rules
.cline/rules
.windsurf/rules
.repomix
repomix-output.txt
# evaluation
evaluation/evaluation_outputs
+81
View File
@@ -15,10 +15,13 @@ make build && make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
* If you've made changes to the backend, you should run `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml` (this will run on staged files).
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
* If you've made changes to the VSCode extension, you should run `cd openhands/integrations/vscode && npm run lint:fix && npm run compile ; cd ../../..`
The pre-commit hooks MUST pass successfully before pushing any changes to the repository. This is a mandatory requirement to maintain code quality and consistency.
@@ -60,6 +63,22 @@ Frontend:
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
VSCode Extension:
- Located in the `openhands/integrations/vscode` directory
- Setup: Run `npm install` in the extension directory
- Linting:
- Run linting with fixes: `npm run lint:fix`
- Check only: `npm run lint`
- Type checking: `npm run typecheck`
- Building:
- Compile TypeScript: `npm run compile`
- Package extension: `npm run package-vsix`
- Testing:
- Run tests: `npm run test`
- Development Best Practices:
- Use `vscode.window.createOutputChannel()` for debug logging instead of `showErrorMessage()` popups
- Pre-commit process runs both frontend and backend checks when committing extension changes
## Template for Github Pull Request
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.
@@ -118,3 +137,65 @@ Your specialized knowledge and instructions here...
2. Add the setting to the backend:
- Add the setting to the `Settings` model in `openhands/storage/data_models/settings.py`
- Update any relevant backend code to apply the setting (e.g., in session creation)
### Adding New LLM Models
To add a new LLM model to OpenHands, you need to update multiple files across both frontend and backend:
#### Model Configuration Procedure:
1. **Frontend Model Arrays** (`frontend/src/utils/verified-models.ts`):
- Add the model to `VERIFIED_MODELS` array (main list of all verified models)
- Add to provider-specific arrays based on the model's provider:
- `VERIFIED_OPENAI_MODELS` for OpenAI models
- `VERIFIED_ANTHROPIC_MODELS` for Anthropic models
- `VERIFIED_MISTRAL_MODELS` for Mistral models
- `VERIFIED_OPENHANDS_MODELS` for models available through OpenHands provider
2. **Backend CLI Integration** (`openhands/cli/utils.py`):
- Add the model to the appropriate `VERIFIED_*_MODELS` arrays
- This ensures the model appears in CLI model selection
3. **Backend Model List** (`openhands/utils/llm.py`):
- **CRITICAL**: Add the model to the `openhands_models` list (lines 57-66) if using OpenHands provider
- This is required for the model to appear in the frontend model selector
- Format: `'openhands/model-name'` (e.g., `'openhands/o3'`)
4. **Backend LLM Configuration** (`openhands/llm/llm.py`):
- Add to feature-specific arrays based on model capabilities:
- `FUNCTION_CALLING_SUPPORTED_MODELS` if the model supports function calling
- `REASONING_EFFORT_SUPPORTED_MODELS` if the model supports reasoning effort parameters
- `CACHE_PROMPT_SUPPORTED_MODELS` if the model supports prompt caching
- `MODELS_WITHOUT_STOP_WORDS` if the model doesn't support stop words
5. **Validation**:
- Run backend linting: `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml`
- Run frontend linting: `cd frontend && npm run lint:fix`
- Run frontend build: `cd frontend && npm run build`
#### Model Verification Arrays:
- **VERIFIED_MODELS**: Main array of all verified models shown in the UI
- **VERIFIED_OPENAI_MODELS**: OpenAI models (LiteLLM doesn't return provider prefix)
- **VERIFIED_ANTHROPIC_MODELS**: Anthropic models (LiteLLM doesn't return provider prefix)
- **VERIFIED_MISTRAL_MODELS**: Mistral models (LiteLLM doesn't return provider prefix)
- **VERIFIED_OPENHANDS_MODELS**: Models available through OpenHands managed provider
#### Model Feature Support Arrays:
- **FUNCTION_CALLING_SUPPORTED_MODELS**: Models that support structured function calling
- **REASONING_EFFORT_SUPPORTED_MODELS**: Models that support reasoning effort parameters (like o1, o3)
- **CACHE_PROMPT_SUPPORTED_MODELS**: Models that support prompt caching for efficiency
- **MODELS_WITHOUT_STOP_WORDS**: Models that don't support stop word parameters
#### Frontend Model Integration:
- Models are automatically available in the model selector UI once added to verified arrays
- The `extractModelAndProvider` utility automatically detects provider from model arrays
- Provider-specific models are grouped and prioritized in the UI selection
#### CLI Model Integration:
- Models appear in CLI provider selection based on the verified arrays
- The `organize_models_and_providers` function groups models by provider
- Default model selection prioritizes verified models for each provider
+1 -1
View File
@@ -31,7 +31,7 @@ We're always looking to improve the look and feel of the application. If you've
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
of the application, please open an issue first, or better, join the #frontend channel in our Slack
of the application, please open an issue first, or better, join the #eng-ui-ux channel in our Slack
to gather consensus from our design team first.
#### Improving the agent
+1 -1
View File
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.48-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.49-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -62,17 +62,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.48
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
+3 -3
View File
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.48
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
+3 -3
View File
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.48
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
+114
View File
@@ -0,0 +1,114 @@
import os
import pathlib
import subprocess
# This script is intended to be run by Poetry during the build process.
# Define the expected name of the .vsix file based on the extension's package.json
# This should match the name and version in openhands-vscode/package.json
EXTENSION_NAME = 'openhands-vscode'
EXTENSION_VERSION = '0.0.1'
VSIX_FILENAME = f'{EXTENSION_NAME}-{EXTENSION_VERSION}.vsix'
# Paths
ROOT_DIR = pathlib.Path(__file__).parent.resolve()
VSCODE_EXTENSION_DIR = ROOT_DIR / 'openhands' / 'integrations' / 'vscode'
def check_node_version():
"""Check if Node.js version is sufficient for building the extension."""
try:
result = subprocess.run(
['node', '--version'], capture_output=True, text=True, check=True
)
version_str = result.stdout.strip()
# Extract major version number (e.g., "v12.22.9" -> 12)
major_version = int(version_str.lstrip('v').split('.')[0])
return major_version >= 18 # Align with frontend actual usage (18.20.1)
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
return False
def build_vscode_extension():
"""Builds the VS Code extension."""
vsix_path = VSCODE_EXTENSION_DIR / VSIX_FILENAME
# Check if VSCode extension build is disabled via environment variable
if os.environ.get('SKIP_VSCODE_BUILD', '').lower() in ('1', 'true', 'yes'):
print('--- Skipping VS Code extension build (SKIP_VSCODE_BUILD is set) ---')
if vsix_path.exists():
print(f'--- Using existing VS Code extension: {vsix_path} ---')
else:
print('--- No pre-built VS Code extension found ---')
return
# Check Node.js version - if insufficient, use pre-built extension as fallback
if not check_node_version():
print('--- Warning: Node.js version < 18 detected or Node.js not found ---')
print('--- Skipping VS Code extension build (requires Node.js >= 18) ---')
print('--- Using pre-built extension if available ---')
if not vsix_path.exists():
print('--- Warning: No pre-built VS Code extension found ---')
print('--- VS Code extension will not be available ---')
else:
print(f'--- Using pre-built VS Code extension: {vsix_path} ---')
return
print(f'--- Building VS Code extension in {VSCODE_EXTENSION_DIR} ---')
try:
# Ensure npm dependencies are installed
print('--- Running npm install for VS Code extension ---')
subprocess.run(
['npm', 'install'],
cwd=VSCODE_EXTENSION_DIR,
check=True,
shell=os.name == 'nt',
)
# Package the extension
print(f'--- Packaging VS Code extension ({VSIX_FILENAME}) ---')
subprocess.run(
['npm', 'run', 'package-vsix'],
cwd=VSCODE_EXTENSION_DIR,
check=True,
shell=os.name == 'nt',
)
# Verify the generated .vsix file exists
if not vsix_path.exists():
raise FileNotFoundError(
f'VS Code extension package not found after build: {vsix_path}'
)
print(f'--- VS Code extension built successfully: {vsix_path} ---')
except subprocess.CalledProcessError as e:
print(f'--- Warning: Failed to build VS Code extension: {e} ---')
print('--- Continuing without building extension ---')
if not vsix_path.exists():
print('--- Warning: No pre-built VS Code extension found ---')
print('--- VS Code extension will not be available ---')
def build(setup_kwargs):
"""
This function is called by Poetry during the build process.
`setup_kwargs` is a dictionary that will be passed to `setuptools.setup()`.
"""
print('--- Running custom Poetry build script (build_vscode.py) ---')
# Build the VS Code extension and place the .vsix file
build_vscode_extension()
# Poetry will handle including files based on pyproject.toml `include` patterns.
# Ensure openhands/integrations/vscode/*.vsix is included there.
print('--- Custom Poetry build script (build_vscode.py) finished ---')
if __name__ == '__main__':
print('Running build_vscode.py directly for testing VS Code extension packaging...')
build_vscode_extension()
print('Direct execution of build_vscode.py finished.')
+7 -3
View File
@@ -18,9 +18,6 @@
# Cache directory path
#cache_dir = "/tmp/cache"
# Reasoning effort for o1 models (low, medium, high, or not set)
#reasoning_effort = "medium"
# Debugging enabled
#debug = false
@@ -49,6 +46,9 @@
# Maximum file size for uploads, in megabytes
#file_uploads_max_file_size_mb = 0
# Enable the browser environment
#enable_browser = true
# Maximum budget per task, 0.0 means no limit
#max_budget_per_task = 0.0
@@ -116,6 +116,9 @@ api_key = ""
# API version
#api_version = ""
# Reasoning effort for OpenAI o-series models (low, medium, high, or not set)
#reasoning_effort = "medium"
# Cost per input token
#input_cost_per_token = 0.0
@@ -226,6 +229,7 @@ model = "gpt-4o"
[agent]
# Whether the browsing tool is enabled
# Note: when this is set to true, enable_browser in the core config must also be true
enable_browsing = true
# Whether the LLM draft editor is enabled
+1
View File
@@ -45,6 +45,7 @@ ENV OPENHANDS_BUILD_VERSION=$OPENHANDS_BUILD_VERSION
ENV SANDBOX_USER_ID=0
ENV FILE_STORE=local
ENV FILE_STORE_PATH=/.openhands
ENV INIT_GIT_IN_EMPTY_WORKSPACE=1
RUN mkdir -p $FILE_STORE_PATH
RUN mkdir -p $WORKSPACE_BASE
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.48-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.49-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+3
View File
@@ -34,6 +34,7 @@
{
"group": "Integrations",
"pages": [
"usage/cloud/bitbucket-installation",
"usage/cloud/github-installation",
"usage/cloud/gitlab-installation",
"usage/cloud/slack-installation"
@@ -66,7 +67,9 @@
"usage/llms/groq",
"usage/llms/local-llms",
"usage/llms/litellm-proxy",
"usage/llms/moonshot",
"usage/llms/openai-llms",
"usage/llms/openhands-llms",
"usage/llms/openrouter"
]
}
+5
View File
@@ -1827,6 +1827,11 @@
"updated_at": {
"type": "string",
"format": "date-time"
},
"owner_type": {
"type": "string",
"enum": ["user", "organization"],
"nullable": true
}
}
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,25 @@
---
title: Bitbucket Integration
description: This guide walks you through the process of installing OpenHands Cloud for your Bitbucket repositories. Once
set up, it will allow OpenHands to work with your Bitbucket repository.
---
## Prerequisites
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a Bitbucket account](/usage/cloud/openhands-cloud).
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.
## Working With Bitbucket Repos in Openhands Cloud
After signing in with a Bitbucket account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo-no-github.png)
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
+3 -2
View File
@@ -9,8 +9,9 @@ description: The Cloud UI provides a web interface for interacting with OpenHand
The landing page is where you can:
- [Add GitHub repository access](/usage/cloud/github-installation#adding-github-repository-access) to OpenHands.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud) or
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) to start working on.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud),
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) or
[a Bitbucket repo](/usage/cloud/bitbucket-installation#working-with-bitbucket-repos-in-openhands-cloud) to start working on.
- See `Suggested Tasks` for repositories that OpenHands has access to.
- Launch an empty conversation using `Launch from Scratch`.
+3 -2
View File
@@ -51,8 +51,7 @@ Giving GitHub repository access to OpenHands also allows you to work on GitHub i
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with
`@openhands`. OpenHands will:
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it.
- You can click on the link to track the progress on OpenHands Cloud.
2. Open a pull request if it determines that the issue has been successfully resolved.
@@ -65,6 +64,8 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
- Request updates
- Get code explanations
**Important Note**: The `@openhands` mention functionality in pull requests only works if the pull request is both *to* and *from* a repository that you have added through the interface. This is because OpenHands needs appropriate permissions to access both repositories.
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
+29 -2
View File
@@ -1,7 +1,7 @@
---
title: GitLab Integration
description: This guide walks you through the process of installing OpenHands Cloud for your GitLab repositories. Once
set up, it will allow OpenHands to work with your GitLab repository.
set up, it will allow OpenHands to work with your GitLab repository through the Cloud UI or straight from GitLab!.
---
## Prerequisites
@@ -17,7 +17,7 @@ Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have acc
After signing in with a Gitlab account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo.png)
![Connect Repo](/static/img/connect-repo-no-github.png)
## Using Tokens with Reduced Scopes
@@ -25,6 +25,33 @@ OpenHands requests an API-scoped token during OAuth authentication. By default,
To restrict the agent's permissions, you can define a custom secret `GITLAB_TOKEN`, which will override the default token assigned to the agent.
While the high-permission API token is still requested and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
## Working on GitLab Issues and Merge Requests Using Openhands
<Note>
This feature works for personal projects and is available for group projects with a
[Premium or Ultimate tier subscription](https://docs.gitlab.com/user/project/integrations/webhooks/#group-webhooks).
A webhook is automatically installed within a few minutes after the owner/maintainer of the project or group logs into
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but we are planning to improve this in a future release.
</Note>
Giving GitLab repository access to OpenHands also allows you to work on GitLab issues and merge requests directly.
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it.
- You can click on the link to track the progress on OpenHands Cloud.
2. Open a merge request if it determines that the issue has been successfully resolved.
3. Comment on the issue with a summary of the performed tasks and a link to the PR.
### Working with Merge Requests
To get OpenHands to work on merge requests, mention `@openhands` in the comments to:
- Ask questions
- Request updates
- Get code explanations
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
+3 -2
View File
@@ -8,9 +8,9 @@ description: Getting started with OpenHands Cloud.
OpenHands Cloud is the hosted cloud version of All Hands AI's OpenHands. To get started with OpenHands Cloud,
visit [app.all-hands.dev](https://app.all-hands.dev).
You'll be prompted to connect with your GitHub or GitLab account:
You'll be prompted to connect with your GitHub, GitLab or Bitbucket account:
1. Click `Log in with GitHub` or `Log in with GitLab`.
1. Click `Log in with GitHub`, `Log in with GitLab` or `Log in with Bitbucket`.
2. Review the permissions requested by OpenHands and authorize the application.
- OpenHands will require certain permissions from your account. To read more about these permissions,
you can click the `Learn more` link on the authorization page.
@@ -22,5 +22,6 @@ Once you've connected your account, you can:
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories.
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories.
- [Install Bitbucket Integration](/usage/cloud/bitbucket-installation) to use OpenHands with your Bitbucket repositories.
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
+6
View File
@@ -8,6 +8,12 @@ description: This page outlines all available configuration options for OpenHand
In GUI Mode, any settings applied through the Settings UI will take precedence.
</Note>
## Location of the `config.toml` File
When running OpenHands in CLI, headless, or development mode, you can use a project-specific `config.toml` file for configuration, which must be
located in the same directory from which the command is run. Alternatively, you may use the `--config-file` option to
specify a different path to the `config.toml` file.
## Core Configuration
The core configuration options are defined in the `[core]` section of the `config.toml` file.
+2 -1
View File
@@ -12,7 +12,8 @@ icon: question
[GitHub](/usage/cloud/github-installation), [GitLab](/usage/cloud/gitlab-installation),
and [Slack](/usage/cloud/slack-installation) integrations.
2. **Run on your own**: If you prefer to run it on your own hardware, follow our [Getting Started guide](/usage/local-setup).
3. **First steps**: Complete the [start building tutorial](/usage/getting-started) to learn the basics.
3. **First steps**: Read over the [start building guidelines](/usage/getting-started) and
[prompting best practices](/usage/prompting/prompting-best-practices) to learn the basics.
### Can I use OpenHands for production workloads?
+43 -3
View File
@@ -33,6 +33,45 @@ pip install openhands-ai
uvx --python 3.12 --from openhands-ai openhands
```
<AccordionGroup>
<Accordion title="Create shell aliases for easy access across environments">
Add the following to your shell configuration file (`.bashrc`, `.zshrc`, etc.):
```bash
# Add OpenHands aliases
alias openhands="uvx --python 3.12 --from openhands-ai openhands"
alias oh="uvx --python 3.12 --from openhands-ai openhands"
```
After adding these lines, reload your shell configuration with `source ~/.bashrc` or `source ~/.zshrc` (depending on your shell).
</Accordion>
<Accordion title="Install OpenHands in home directory without global installation">
You can install OpenHands in a virtual environment in your home directory using `uv`:
```bash
# Create a virtual environment in your home directory
cd ~
uv venv .openhands-venv --python 3.12
# Install OpenHands in the virtual environment
uv pip install -t ~/.openhands-venv/lib/python3.12/site-packages openhands-ai
# Add the bin directory to your PATH in your shell configuration file
echo 'export PATH="$PATH:$HOME/.openhands-venv/bin"' >> ~/.bashrc # or ~/.zshrc
# Reload your shell configuration
source ~/.bashrc # or source ~/.zshrc
```
</Accordion>
</AccordionGroup>
2. Launch an interactive OpenHands conversation from the command line:
```bash
openhands
@@ -64,7 +103,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +112,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.48 \
docker.all-hands.dev/all-hands-ai/openhands:0.49 \
python -m openhands.cli.main --override-cli-mode true
```
@@ -84,7 +123,8 @@ docker run -it \
This launches the CLI in Docker, allowing you to interact with OpenHands.
The `-e SANDBOX_USER_ID=$(id -u)` ensures files created by the agent in your workspace have the correct permissions.
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host users
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
The conversation history will be saved in `~/.openhands/sessions`.
+4 -5
View File
@@ -25,7 +25,8 @@ You can use the Settings page at any time to:
- Setup the LLM provider and model for OpenHands.
- [Setup the search engine](/usage/search-engine-setup).
- [Configure MCP servers](/usage/mcp).
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup) and [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup).
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup), [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup)
and [connect to Bitbucket](/usage/how-to/gui-mode#bitbucket-setup).
- Set application settings like your preferred language, notifications and other preferences.
- [Manage custom secrets](/usage/common-settings#secrets-management).
@@ -122,17 +123,15 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
</Accordion>
</AccordionGroup>
#### BitBucket Setup (Coming soon ...)
#### BitBucket Setup
<AccordionGroup>
<Accordion title="Setting Up a BitBucket Password">
1. **Generate an App Password**:
- On BitBucket, go to Personal Settings > App Password.
- Create a new password with the following scopes:
- `repository: read`
- `account`: `read`
- `repository: write`
- `pull requests: read`
- `pull requests: write`
- `issues: read`
- `issues: write`
- App passwords are non-expiring token. OpenHands will migrate to using API tokens in the future.
2. **Enter Token in OpenHands**:
+4 -3
View File
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,13 +73,14 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.48 \
docker.all-hands.dev/all-hands-ai/openhands:0.49 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
> **Note**: If you used OpenHands before version 0.44, run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history.
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host users
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
## Additional Options
@@ -90,6 +91,6 @@ Common command-line options:
- `-b 10.0` - Set budget limit (USD)
- `--no-auto-continue` - Interactive mode
Run `poetry run python -m openhands.core.main --help` for all options, or use a [`config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) for more flexibility.
Run `poetry run python -m openhands.core.main --help` for all options.
Set `export LOG_ALL_EVENTS=true` to log all agent actions.
+9 -4
View File
@@ -10,7 +10,8 @@ This section is for users who want to connect OpenHands to different LLMs.
## Model Recommendations
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some
recommendations for model selection. Our latest benchmarking results can be found in [this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
recommendations for model selection. Our latest benchmarking results can be found in
[this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
Based on these findings and community feedback, these are the latest models that have been verified to work reasonably well with OpenHands:
@@ -20,6 +21,7 @@ Based on these findings and community feedback, these are the latest models that
- [openai/o4-mini](https://openai.com/index/introducing-o3-and-o4-mini/)
- [gemini/gemini-2.5-pro](https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/)
- [deepseek/deepseek-chat](https://api-docs.deepseek.com/)
- [moonshot/kimi-k2-0711-preview](https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2)
If you have successfully run OpenHands with specific providers, we encourage you to open a PR to share your setup process
to help others using the same provider!
@@ -70,17 +72,20 @@ We have a few guides for running OpenHands with specific model providers:
- [Groq](/usage/llms/groq)
- [Local LLMs with SGLang or vLLM](/usage/llms/local-llms)
- [LiteLLM Proxy](/usage/llms/litellm-proxy)
- [Moonshot AI](/usage/llms/moonshot)
- [OpenAI](/usage/llms/openai-llms)
- [OpenHands](/usage/llms/openhands-llms)
- [OpenRouter](/usage/llms/openrouter)
## Model Customization
LLM providers have specific settings that can be customized to optimize their performance with OpenHands, such as:
- **Custom Tokenizers**: For specialized models, you can add a suitable tokenizer
- **Native Tool Calling**: Toggle native function/tool calling capabilities
- **Custom Tokenizers**: For specialized models, you can add a suitable tokenizer.
- **Native Tool Calling**: Toggle native function/tool calling capabilities.
For detailed information about model customization, see [LLM Configuration Options](configuration-options#llm-customization).
For detailed information about model customization, see
[LLM Configuration Options](/usage/configuration-options#llm-configuration).
### API retries and rate limits
+25 -4
View File
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.48
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
2. Wait until the server is running (see log below):
```
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.48
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.49
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
@@ -175,6 +175,27 @@ vllm serve mistralai/Devstral-Small-2505 \
--enable-prefix-caching
```
If you are interested in further improved inference speed, you can also try Snowflake's version
of vLLM, [ArcticInference](https://www.snowflake.com/en/engineering-blog/fast-speculative-decoding-vllm-arctic/),
which can achieve up to 2x speedup in some cases.
1. Install the Arctic Inference library that automatically patches vLLM:
```bash
pip install git+https://github.com/snowflakedb/ArcticInference.git
```
2. Run the launch command with speculative decoding enabled:
```bash
vllm serve mistralai/Devstral-Small-2505 \
--host 0.0.0.0 --port 8000 \
--api-key mykey \
--tensor-parallel-size 2 \
--served-model-name Devstral-Small-2505 \
--speculative-config '{"method": "suffix"}'
```
### Run OpenHands (Alternative Backends)
#### Using Docker
+25
View File
@@ -0,0 +1,25 @@
---
title: Moonshot AI
description: How to use Moonshot AI models with OpenHands
---
## Using Moonshot AI with OpenHands
[Moonshot AI](https://platform.moonshot.ai/) offers several powerful models, including Kimi-K2, which has been verified to work well with OpenHands.
### Setup
1. Sign up for an account at [Moonshot AI Platform](https://platform.moonshot.ai/)
2. Generate an API key from your account settings
3. Configure OpenHands to use Moonshot AI:
| Setting | Value |
| --- | --- |
| LLM Provider | `moonshot` |
| LLM Model | `kimi-k2-0711-preview` |
| API Key | Your Moonshot API key |
### Recommended Models
- `moonshot/kimi-k2-0711-preview` - Kimi-K2 is Moonshot's most powerful model with a 131K context window, function calling support, and web search capabilities.
+34
View File
@@ -0,0 +1,34 @@
---
title: OpenHands
description: OpenHands LLM provider with access to state-of-the-art (SOTA) agentic coding models.
---
## Obtain Your OpenHands LLM API Key
1. [Log in to OpenHands Cloud](/usage/cloud/openhands-cloud).
2. Go to the Settings page and navigate to the `API Keys` tab.
3. Copy your `LLM API Key`.
![OpenHands LLM API Key](/static/img/openhands-llm-api-key.png)
## Configuration
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
- `LLM Provider` to `OpenHands`
- `LLM Model` to the model you will be using (e.g. claude-sonnet-4-20250514)
- `API Key` to your OpenHands LLM API key copied from above
## Using OpenHands LLM Provider in the CLI
1. [Run OpenHands CLI](/usage/how-to/cli-mode).
2. To select OpenHands as the LLM provider:
- If this is your first time running the CLI, choose `openhands` and then select the model that you would like to use.
- If you have previously run the CLI, run the `/settings` command and select to modify the `Basic` settings. Then
choose `openhands` and finally the model.
![OpenHands Provider in CLI](/static/img/openhands-provider-cli.png)
## Pricing
Pricing follows official API provider rates.
[You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
+3 -3
View File
@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
### Start the App
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.48
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
+46 -1
View File
@@ -29,6 +29,15 @@ sse_servers = [
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
# SHTTP Servers - External servers that communicate via Streamable HTTP
shttp_servers = [
# Basic SHTTP server with just a URL
"http://example.com:8080/mcp",
# SHTTP server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
# Stdio Servers - Local processes that communicate via standard input/output
stdio_servers = [
# Basic stdio server
@@ -57,6 +66,22 @@ SSE servers are configured using either a string URL or an object with the follo
- Type: `str`
- Description: The URL of the SSE server
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication
### SHTTP Servers
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
- `url` (required)
- Type: `str`
- Description: The URL of the SHTTP server
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication
### Stdio Servers
Stdio servers are configured using an object with the following properties:
@@ -84,7 +109,7 @@ Stdio servers are configured using an object with the following properties:
When OpenHands starts, it:
1. Reads the MCP configuration.
2. Connects to any configured SSE servers.
2. Connects to any configured SSE and SHTTP servers.
3. Starts any configured stdio servers.
4. Registers the tools provided by these servers with the agent.
@@ -93,3 +118,23 @@ The agent can then use these tools just like any built-in tool. When the agent c
1. OpenHands routes the call to the appropriate MCP server.
2. The server processes the request and returns a response.
3. OpenHands converts the response to an observation and presents it to the agent.
## Transport Protocols
OpenHands supports three different MCP transport protocols:
### Server-Sent Events (SSE)
SSE is a legacy HTTP-based transport that uses Server-Sent Events for server-to-client communication and HTTP POST requests for client-to-server communication. This transport is suitable for basic streaming scenarios but has limitations in session management and connection resumability.
### Streamable HTTP (SHTTP)
SHTTP is the modern HTTP-based transport protocol that provides enhanced features over SSE:
- **Improved Session Management**: Supports stateful sessions with session IDs for maintaining context across requests
- **Connection Resumability**: Can resume broken connections and replay missed messages using event IDs
- **Bidirectional Communication**: Uses HTTP POST for client-to-server and optional SSE streams for server-to-client communication
- **Better Error Handling**: Enhanced error reporting and recovery mechanisms
SHTTP is the recommended transport for HTTP-based MCP servers as it provides better reliability and features compared to the legacy SSE transport.
### Standard Input/Output (stdio)
Stdio transport enables communication through standard input and output streams, making it ideal for local integrations and command-line tools. This transport is used for locally executed MCP servers that run as separate processes.
+9
View File
@@ -24,3 +24,12 @@ General microagent file example for organization `Great-Co` located inside the `
```
For GitLab organizations, the same microagent would be located inside the `openhands-config` repository.
## User Microagents When Running Openhands on Your Own
<Note>
This works with CLI, headless and development modes. It does not work out of the box when running OpenHands using the docker command.
</Note>
When running OpenHands on your own, you can place microagents in the `~/.openhands/microagents` folder on your local
system and OpenHands will always load it for all your conversations.
@@ -38,6 +38,21 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
OpenHands.
### Internal Server Error. Ports are not available
**Description**
When running on Windows, the error `Internal Server Error ("ports are not available: exposing port TCP
...: bind: An attempt was made to access a socket in a
way forbidden by its access permissions.")` is encountered.
**Resolution**
* Run the following command in PowerShell, as Administrator to reset the NAT service and release the ports:
```
Restart-Service -Name "winnat"
```
### Unable to access VS Code tab via local IP
**Description**
+2 -1
View File
@@ -101,13 +101,14 @@ The OpenHands evaluation harness supports a wide variety of benchmarks across [s
- SWE-Bench: [`evaluation/benchmarks/swe_bench`](./benchmarks/swe_bench)
- HumanEvalFix: [`evaluation/benchmarks/humanevalfix`](./benchmarks/humanevalfix)
- BIRD: [`evaluation/benchmarks/bird`](./benchmarks/bird)
- BioCoder: [`evaluation/benchmarks/ml_bench`](./benchmarks/ml_bench)
- BioCoder: [`evaluation/benchmarks/biocoder`](./benchmarks/biocoder)
- ML-Bench: [`evaluation/benchmarks/ml_bench`](./benchmarks/ml_bench)
- APIBench: [`evaluation/benchmarks/gorilla`](./benchmarks/gorilla/)
- ToolQA: [`evaluation/benchmarks/toolqa`](./benchmarks/toolqa/)
- AiderBench: [`evaluation/benchmarks/aider_bench`](./benchmarks/aider_bench/)
- Commit0: [`evaluation/benchmarks/commit0_bench`](./benchmarks/commit0_bench/)
- DiscoveryBench: [`evaluation/benchmarks/discoverybench`](./benchmarks/discoverybench/)
- TerminalBench: [`evaluation/benchmarks/terminal_bench`](./benchmarks/terminal_bench)
### Web Browsing
@@ -41,6 +41,10 @@ default, it is set to 1.
- `language`, the language of your evaluating dataset.
- `dataset`, the absolute position of the dataset jsonl.
**Skipping errors on build**
For debugging purposes, you can set `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=true` to continue evaluation even when instances reach maximum retries. After evaluation completes, check `maximum_retries_exceeded.jsonl` for a list of failed instances, fix those issues, and then run the evaluation again with `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false`.
The results will be generated in evaluation/evaluation_outputs/outputs/XXX/CodeActAgent/YYY/output.jsonl, you can refer to the [example](examples/output.jsonl).
## Runing evaluation
@@ -17,6 +17,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
assert_and_raise,
check_maximum_retries_exceeded,
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
@@ -843,3 +844,5 @@ if __name__ == '__main__':
timeout_seconds=120 * 60, # 2 hour PER instance should be more than enough
max_retries=5,
)
# Check if any instances reached maximum retries
check_maximum_retries_exceeded(metadata.eval_output_dir)
@@ -38,6 +38,10 @@ Please follow instruction [here](../../README.md#setup) to setup your local deve
> - If your LLM config has temperature=0, we will automatically use temperature=0.1 for the 2nd and 3rd attempts
>
> To enable this iterative protocol, set `export ITERATIVE_EVAL_MODE=true`
>
> **Skipping errors on build**
>
> For debugging purposes, you can set `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=true` to continue evaluation even when instances reach maximum retries. After evaluation completes, check `maximum_retries_exceeded.jsonl` for a list of failed instances, fix those issues, and then run the evaluation again with `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false`.
### Running Locally with Docker
@@ -0,0 +1,45 @@
# **Localization Evaluation for SWE-Bench**
This folder implements localization evaluation at both file and function levels to complementing the assessment of agent inference on [SWE-Bench](https://www.swebench.com/).
## **1. Environment Setup**
- Python env: [Install python environment](../../../README.md#development-environment)
- LLM config: [Configure LLM config](../../../README.md#configure-openhands-and-your-llm)
## **2. Inference & Evaluation**
- Inference and evaluation follow the original `run_infer.sh` and `run_eval.sh` implementation
- You may refer to instructions at [README.md](../README.md) for running inference and evaluation on SWE-Bench
## **3. Localization Evaluation**
- Localization evaluation computes two-level localization accuracy, while also considers task success as an additional metric for overall evaluation:
- **File Localization Accuracy:** Accuracy of correctly localizing the target file
- **Function Localization Accuracy:** Accuracy of correctly localizing the target function
- **Resolve Rate** (will be auto-skipped if missing): Success rate of whether tasks are successfully resolved
- **File Localization Efficiency:** Average number of iterations taken to successfully localize the target file
- **Function Localization Efficiency:** Average number of iterations taken to successfully localize the target file
- **Task success efficiency:** Average number of iterations taken to resolve the task
- **Resource efficiency:** the API expenditure of the agent running inference on SWE-Bench instances
- Run localization evaluation
- Format:
```bash
./evaluation/benchmarks/swe_bench/scripts/eval_localization.sh [infer-dir] [split] [dataset] [max-infer-turn] [align-with-max]
```
- `infer-dir`: inference directory containing inference outputs
- `split`: SWE-Bench dataset split to use
- `dataset`: SWE-Bench dataset name
- `max-infer-turn`: the maximum number of iterations the agent took to run inference
- `align-with-max`: whether to align failure indices (e.g., incorrect localization, unresolved tasks) with `max_iter`
- Example:
```bash
# Example
./evaluation/benchmarks/swe_bench/scripts/eval_localization.sh \
--infer-dir ./evaluation/evaluation_outputs/outputs/princeton-nlp__SWE-bench_Verified-test/CodeActAgent/gpt_4o_100_N \
--split test \
--dataset princeton-nlp/SWE-bench_Verified \
--max-infer-turn 100 \
--align-with-max true
```
- Localization evaluation results will be automatically saved to `[infer-dir]/loc_eval`
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -28,6 +28,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
assert_and_raise,
check_maximum_retries_exceeded,
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
@@ -109,9 +110,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata) -> MessageActio
template_name = 'swt.j2'
elif mode == 'swe':
if 'claude' in llm_model:
template_name = 'swe_claude.j2'
elif 'gemini' in llm_model:
template_name = 'swe_gemini.j2'
template_name = 'swe_default.j2'
elif 'gpt-4.1' in llm_model:
template_name = 'swe_gpt4.j2'
else:
@@ -970,3 +969,5 @@ if __name__ == '__main__':
logger.info(
f'Done! Total {len(added_instance_ids)} instances added to {output_file}'
)
# Check if any instances reached maximum retries
check_maximum_retries_exceeded(metadata.eval_output_dir)
@@ -0,0 +1,227 @@
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"
# Function to display usage information
usage() {
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " --infer-dir DIR Directory containing model inference outputs"
echo " --split SPLIT SWE-Bench dataset split selection"
echo " --dataset DATASET Dataset name"
echo " --max-infer-turn NUM Max number of turns for coding agent"
echo " --align-with-max BOOL Align failed instance indices with max iteration (true/false)"
echo " -h, --help Display this help message"
echo ""
echo "Example:"
echo " $0 --infer-dir ./inference_outputs --split test --align-with-max false"
}
# Check if no arguments were provided
if [ $# -eq 0 ]; then
usage
exit 1
fi
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--infer-dir)
INFER_DIR="$2"
shift 2
;;
--split)
SPLIT="$2"
shift 2
;;
--dataset)
DATASET="$2"
shift 2
;;
--max-infer-turn)
MAX_TURN="$2"
shift 2
;;
--align-with-max)
ALIGN_WITH_MAX="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1"
usage
exit 1
;;
esac
done
# Check for required arguments (only INFER_DIR is required)
if [ -z "$INFER_DIR" ]; then
echo "Error: Missing required arguments (--infer-dir is required)"
usage
exit 1
fi
# Set defaults for optional arguments if not provided
if [ -z "$SPLIT" ]; then
SPLIT="test"
echo "Split not specified, using default: $SPLIT"
fi
if [ -z "$DATASET" ]; then
DATASET="princeton-nlp/SWE-bench_Verified"
echo "Dataset not specified, using default: $DATASET"
fi
if [ -z "$MAX_TURN" ]; then
MAX_TURN=20
echo "Max inference turn not specified, using default: $MAX_TURN"
fi
if [ -z "$ALIGN_WITH_MAX" ]; then
ALIGN_WITH_MAX="true"
echo "Align with max not specified, using default: $ALIGN_WITH_MAX"
fi
# Validate align-with-max value
if [ "$ALIGN_WITH_MAX" != "true" ] && [ "$ALIGN_WITH_MAX" != "false" ]; then
print_error "Invalid value for --align-with-max: $ALIGN_WITH_MAX. Must be 'true' or 'false'"
exit 1
fi
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_header() {
echo -e "${BLUE}[TASK]${NC} $1"
}
# Check if Python is available
print_header "Checking Python installation..."
if ! command -v python3 &> /dev/null; then
if ! command -v python &> /dev/null; then
print_error "Python is not installed or not in PATH"
exit 1
else
PYTHON_CMD="python"
print_status "Using python command"
fi
else
PYTHON_CMD="python3"
print_status "Using python3 command"
fi
# Check if the Python script exists
SCRIPT_NAME="./evaluation/benchmarks/swe_bench/loc_eval/loc_evaluator.py"
if [ ! -f "$SCRIPT_NAME" ]; then
print_error "Python script '$SCRIPT_NAME' not found in current directory"
print_warning "Make sure the Python script is in the same directory as this bash script"
exit 1
fi
# Check if required directories exist
print_header "Validating directories..."
if [ ! -d "$INFER_DIR" ]; then
print_error "Inference directory not found: $INFER_DIR"
exit 1
fi
# Evaluation outputs
EVAL_DIR="$INFER_DIR/eval_outputs"
# Display configuration
print_header "Starting Localization Evaluation with the following configuration:"
echo " Inference Directory: $INFER_DIR"
if [ -d "$EVAL_DIR" ]; then
echo " Evaluation Directory: $EVAL_DIR"
else
echo " Evaluation Directory: None (evaluation outputs doesn't exist)"
fi
echo " Output Directory: $INFER_DIR/loc_eval"
echo " Split: $SPLIT"
echo " Dataset: $DATASET"
echo " Max Turns: $MAX_TURN"
echo " Align with Max: $ALIGN_WITH_MAX"
echo " Python Command: $PYTHON_CMD"
echo ""
# Check Python dependencies (optional check)
print_header "Checking Python dependencies..."
$PYTHON_CMD -c "
import sys
required_modules = ['pandas', 'json', 'os', 'argparse', 'collections']
missing_modules = []
for module in required_modules:
try:
__import__(module)
except ImportError:
missing_modules.append(module)
if missing_modules:
print(f'Missing required modules: {missing_modules}')
sys.exit(1)
else:
print('All basic dependencies are available')
" || {
print_error "Some Python dependencies are missing"
print_warning "Please install required packages: pip install pandas"
exit 1
}
# Create log directory if doesn't exists
mkdir -p "$INFER_DIR/loc_eval"
# Set up logging
LOG_FILE="$INFER_DIR/loc_eval/loc_evaluation_$(date +%Y%m%d_%H%M%S).log"
print_status "Logging output to: $LOG_FILE"
# Build the command
CMD_ARGS="\"$SCRIPT_NAME\" \
--infer-dir \"$INFER_DIR\" \
--split \"$SPLIT\" \
--dataset \"$DATASET\" \
--max-infer-turn \"$MAX_TURN\" \
--align-with-max \"$ALIGN_WITH_MAX\""
# Run the Python script
print_header "Running localization evaluation..."
eval "$PYTHON_CMD $CMD_ARGS" 2>&1 | tee "$LOG_FILE"
# Check if the script ran successfully
if [ ${PIPESTATUS[0]} -eq 0 ]; then
print_status "Localization evaluation completed successfully!"
print_status "Results saved to: $INFER_DIR/loc_eval"
print_status "Log file: $LOG_FILE"
# Display summary if results exist
if [ -f "$INFER_DIR/loc_eval/loc_eval_results/loc_acc/overall_eval.json" ]; then
print_header "Evaluation Summary:"
cat "$INFER_DIR/loc_eval/loc_eval_results/loc_acc/overall_eval.json"
echo
fi
else
print_error "Localization evaluation failed!"
print_warning "Check the log file for details: $LOG_FILE"
exit 1
fi
@@ -0,0 +1,31 @@
# Terminal-Bench Evaluation on OpenHands
Terminal-Bench has its own evaluation harness that is very different from OpenHands'. We
implemented [OpenHands agent](https://github.com/laude-institute/terminal-bench/tree/main/terminal_bench/agents/installed_agents/openhands) using OpenHands local runtime
inside terminal-bench framework. Hereby we introduce how to use the terminal-bench
harness to evaluate OpenHands.
## Installation
Terminal-bench ships a CLI tool to manage tasks and run evaluation.
Please follow official [Installation Doc](https://www.tbench.ai/docs/installation). You could also clone terminal-bench [source code](https://github.com/laude-institute/terminal-bench) and use `uv run tb` CLI.
## Evaluation
Please see [Terminal-Bench Leaderboard](https://www.tbench.ai/leaderboard) for the latest
instruction on benchmarking guidance. The dataset might evolve.
Sample command:
```bash
export LLM_BASE_URL=<optional base url>
export LLM_API_KEY=<llm key>
tb run \
--dataset-name terminal-bench-core \
--dataset-version 0.1.1 \
--agent openhands \
--model <model> \
--cleanup
```
You could run `tb --help` or `tb run --help` to learn more about their CLI.
@@ -25,7 +25,8 @@ class Test(BaseIntegrationTest):
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
# git add
action = CmdRunAction(command='git add hello.py .vscode/')
cmd_str = 'git add hello.py'
action = CmdRunAction(command=cmd_str)
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
@@ -40,15 +41,6 @@ class Test(BaseIntegrationTest):
reason=f'Failed to cat /workspace/hello.py: {obs.content}.',
)
# check if the file /workspace/.vscode/settings.json exists
action = CmdRunAction(command='cat /workspace/.vscode/settings.json')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to cat /workspace/.vscode/settings.json: {obs.content}.',
)
# check if the staging area is empty
action = CmdRunAction(command='git status')
obs = runtime.run_action(action)
+94 -5
View File
@@ -311,6 +311,76 @@ def assert_and_raise(condition: bool, msg: str):
raise EvalException(msg)
def log_skipped_maximum_retries_exceeded(instance, metadata, error, max_retries=5):
"""Log and skip the instance when maximum retries are exceeded.
Args:
instance: The instance that failed
metadata: The evaluation metadata
error: The error that occurred
max_retries: The maximum number of retries that were attempted
Returns:
EvalOutput with the error information
"""
from openhands.core.logger import openhands_logger as logger
# Log the error
logger.exception(error)
logger.error(
f'Maximum error retries reached for instance {instance.instance_id}. '
f'Check maximum_retries_exceeded.jsonl, fix the issue and run evaluation again. '
f'Skipping this instance and continuing with others.'
)
# Add the instance name to maximum_retries_exceeded.jsonl in the same folder as output.jsonl
if metadata and metadata.eval_output_dir:
retries_file_path = os.path.join(
metadata.eval_output_dir,
'maximum_retries_exceeded.jsonl',
)
try:
# Write the instance info as a JSON line
with open(retries_file_path, 'a') as f:
import json
# No need to get Docker image as we're not including it in the error entry
error_entry = {
'instance_id': instance.instance_id,
'error': str(error),
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
}
f.write(json.dumps(error_entry) + '\n')
logger.info(f'Added instance {instance.instance_id} to {retries_file_path}')
except Exception as write_error:
logger.error(
f'Failed to write to maximum_retries_exceeded.jsonl: {write_error}'
)
return EvalOutput(
instance_id=instance.instance_id,
test_result={},
error=f'Maximum retries ({max_retries}) reached: {str(error)}',
status='error',
)
def check_maximum_retries_exceeded(eval_output_dir):
"""Check if maximum_retries_exceeded.jsonl exists and output a message."""
from openhands.core.logger import openhands_logger as logger
retries_file_path = os.path.join(eval_output_dir, 'maximum_retries_exceeded.jsonl')
if os.path.exists(retries_file_path):
logger.info(
'ATTENTION: Some instances reached maximum error retries and were skipped.'
)
logger.info(f'These instances are listed in: {retries_file_path}')
logger.info(
'Fix these instances and run evaluation again with EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false'
)
def _process_instance_wrapper(
process_instance_func: Callable[[pd.Series, EvalMetadata, bool], EvalOutput],
instance: pd.Series,
@@ -363,11 +433,26 @@ def _process_instance_wrapper(
+ f'[Encountered after {max_retries} retries. Please check the logs and report the issue.]'
+ '-' * 10
)
# Raise an error after all retries & stop the evaluation
logger.exception(e)
raise RuntimeError(
f'Maximum error retries reached for instance {instance.instance_id}'
) from e
# Check if EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED is set to true
skip_errors = (
os.environ.get(
'EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED', 'false'
).lower()
== 'true'
)
if skip_errors:
# Use the dedicated function to log and skip maximum retries exceeded
return log_skipped_maximum_retries_exceeded(
instance, metadata, e, max_retries
)
else:
# Raise an error after all retries & stop the evaluation
logger.exception(e)
raise RuntimeError(
f'Maximum error retries reached for instance {instance.instance_id}'
) from e
msg = (
'-' * 10
+ '\n'
@@ -456,6 +541,10 @@ def run_evaluation(
output_fp.close()
logger.info('\nEvaluation finished.\n')
# Check if any instances reached maximum retries
if metadata and metadata.eval_output_dir:
check_maximum_retries_exceeded(metadata.eval_output_dir)
def reset_logger_for_multiprocessing(
logger: logging.Logger, instance_id: str, log_dir: str
+2 -1
View File
@@ -13,8 +13,9 @@
"plugin:react-hooks/recommended",
"plugin:@tanstack/query/recommended",
],
"plugins": ["prettier", "unused-imports"],
"plugins": ["prettier", "unused-imports", "i18next"],
"rules": {
"i18next/no-literal-string": "error",
"unused-imports/no-unused-imports": "error",
"prettier/prettier": ["error"],
// Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871
+1 -1
View File
@@ -1,7 +1,7 @@
# Run frontend checks
echo "Running frontend checks..."
cd frontend
npm run check-unlocalized-strings
npm run lint
npm run check-translation-completeness
npx lint-staged
@@ -10,9 +10,7 @@ describe("ChatMessage", () => {
expect(screen.getByText("Hello, World!")).toBeInTheDocument();
});
it.todo("should render an assistant message");
it.skip("should support code syntax highlighting", () => {
it("should support code syntax highlighting", () => {
const code = "```js\nconsole.log('Hello, World!')\n```";
render(<ChatMessage type="user" message={code} />);
@@ -46,8 +44,6 @@ describe("ChatMessage", () => {
);
});
it("should display an error toast if copying content to clipboard fails", async () => {});
it("should render a component passed as a prop", () => {
function Component() {
return <div data-testid="custom-component">Custom Component</div>;
@@ -129,4 +129,159 @@ describe("ActionSuggestions", () => {
expect(createPRPrompt).toContain("meaningful branch name");
expect(createPRPrompt).not.toContain("SAME branch name");
});
it("should use correct provider name based on conversation git_provider, not user authenticated providers", async () => {
// Test case for GitHub repository
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-github",
title: "GitHub Test",
selected_repository: "test-repo",
git_provider: "github",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
// Mock user having both GitHub and Bitbucket tokens
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "github-token",
bitbucket: "bitbucket-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
expect(prButton).toBeInTheDocument();
if (prButton) {
prButton.click();
}
// The suggestion should mention GitHub, not Bitbucket
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("GitHub")
);
expect(onSuggestionsClick).not.toHaveBeenCalledWith(
expect.stringContaining("Bitbucket")
);
});
it("should use GitLab terminology when git_provider is gitlab", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-gitlab",
title: "GitLab Test",
selected_repository: "test-repo",
git_provider: "gitlab",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
gitlab: "gitlab-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
if (prButton) {
prButton.click();
}
// Should mention GitLab and "merge request" instead of "pull request"
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("GitLab")
);
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("merge request")
);
});
it("should use Bitbucket terminology when git_provider is bitbucket", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-bitbucket",
title: "Bitbucket Test",
selected_repository: "test-repo",
git_provider: "bitbucket",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
bitbucket: "bitbucket-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
if (prButton) {
prButton.click();
}
// Should mention Bitbucket
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("Bitbucket")
);
});
});
@@ -44,4 +44,64 @@ describe("AuthModal", () => {
expect(window.location.href).toBe(mockUrl);
});
it("should render Terms of Service and Privacy Policy text with correct links", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
// Find the terms of service section using data-testid
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
expect(termsSection).toBeInTheDocument();
// Check that all text content is present in the paragraph
expect(termsSection).toHaveTextContent(
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
);
expect(termsSection).toHaveTextContent("COMMON$TERMS_OF_SERVICE");
expect(termsSection).toHaveTextContent("COMMON$AND");
expect(termsSection).toHaveTextContent("COMMON$PRIVACY_POLICY");
// Check Terms of Service link
const tosLink = screen.getByRole("link", {
name: "COMMON$TERMS_OF_SERVICE",
});
expect(tosLink).toBeInTheDocument();
expect(tosLink).toHaveAttribute("href", "https://www.all-hands.dev/tos");
expect(tosLink).toHaveAttribute("target", "_blank");
expect(tosLink).toHaveClass("underline", "hover:text-primary");
// Check Privacy Policy link
const privacyLink = screen.getByRole("link", {
name: "COMMON$PRIVACY_POLICY",
});
expect(privacyLink).toBeInTheDocument();
expect(privacyLink).toHaveAttribute(
"href",
"https://www.all-hands.dev/privacy",
);
expect(privacyLink).toHaveAttribute("target", "_blank");
expect(privacyLink).toHaveClass("underline", "hover:text-primary");
// Verify that both links are within the terms section
expect(termsSection).toContainElement(tosLink);
expect(termsSection).toContainElement(privacyLink);
});
it("should open Terms of Service link in new tab", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const tosLink = screen.getByRole("link", {
name: "COMMON$TERMS_OF_SERVICE",
});
expect(tosLink).toHaveAttribute("target", "_blank");
});
it("should open Privacy Policy link in new tab", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const privacyLink = screen.getByRole("link", {
name: "COMMON$PRIVACY_POLICY",
});
expect(privacyLink).toHaveAttribute("target", "_blank");
});
});
@@ -0,0 +1,167 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { LaunchMicroagentModal } from "#/components/features/chat/microagent/launch-microagent-modal";
import { MemoryService } from "#/api/memory-service/memory-service.api";
import { FileService } from "#/api/file-service/file-service.api";
import { I18nKey } from "#/i18n/declaration";
vi.mock("react-router", async () => ({
useParams: vi.fn().mockReturnValue({
conversationId: "123",
}),
}));
// Mock the useHandleRuntimeActive hook
vi.mock("#/hooks/use-handle-runtime-active", () => ({
useHandleRuntimeActive: vi.fn().mockReturnValue({ runtimeActive: true }),
}));
// Mock the useMicroagentPrompt hook
vi.mock("#/hooks/query/use-microagent-prompt", () => ({
useMicroagentPrompt: vi.fn().mockReturnValue({
data: "Generated prompt",
isLoading: false
}),
}));
// Mock the useGetMicroagents hook
vi.mock("#/hooks/query/use-get-microagents", () => ({
useGetMicroagents: vi.fn().mockReturnValue({
data: ["file1", "file2"]
}),
}));
// Mock the useTranslation hook
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
[I18nKey.MICROAGENT$ADD_TO_MICROAGENT]: "Add to Microagent",
[I18nKey.MICROAGENT$WHAT_TO_REMEMBER]: "What would you like your microagent to remember?",
[I18nKey.MICROAGENT$WHERE_TO_PUT]: "Where should we put it?",
[I18nKey.MICROAGENT$ADD_TRIGGERS]: "Add triggers for the microagent",
[I18nKey.MICROAGENT$DESCRIBE_WHAT_TO_ADD]: "Describe what you want to add to the Microagent...",
[I18nKey.MICROAGENT$SELECT_FILE_OR_CUSTOM]: "Select a microagent file or enter a custom value",
[I18nKey.MICROAGENT$TYPE_TRIGGER_SPACE]: "Type a trigger and press Space to add it",
[I18nKey.MICROAGENT$LOADING_PROMPT]: "Loading prompt...",
[I18nKey.MICROAGENT$CANCEL]: "Cancel",
[I18nKey.MICROAGENT$LAUNCH]: "Launch"
};
return translations[key] || key;
},
i18n: {
changeLanguage: vi.fn(),
},
}),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
}));
describe("LaunchMicroagentModal", () => {
const onCloseMock = vi.fn();
const onLaunchMock = vi.fn();
const eventId = 12;
const conversationId = "123";
const renderMicroagentModal = (
{ isLoading }: { isLoading: boolean } = { isLoading: false },
) =>
render(
<LaunchMicroagentModal
onClose={onCloseMock}
onLaunch={onLaunchMock}
eventId={eventId}
selectedRepo="some-repo"
isLoading={isLoading}
/>,
{
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
},
);
afterEach(() => {
vi.clearAllMocks();
});
it("should render the launch microagent modal", () => {
renderMicroagentModal();
expect(screen.getByTestId("launch-microagent-modal")).toBeInTheDocument();
});
it("should render the form fields", () => {
renderMicroagentModal();
// inputs
screen.getByTestId("query-input");
screen.getByTestId("target-input");
screen.getByTestId("trigger-input");
// action buttons
screen.getByRole("button", { name: "Launch" });
screen.getByRole("button", { name: "Cancel" });
});
it("should call onClose when pressing the cancel button", async () => {
renderMicroagentModal();
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await userEvent.click(cancelButton);
expect(onCloseMock).toHaveBeenCalled();
});
it("should display the prompt from the hook", async () => {
renderMicroagentModal();
// Since we're mocking the hook, we just need to verify the UI shows the data
const descriptionInput = screen.getByTestId("query-input");
expect(descriptionInput).toHaveValue("Generated prompt");
});
it("should display the list of microagent files from the hook", async () => {
renderMicroagentModal();
// Since we're mocking the hook, we just need to verify the UI shows the data
const targetInput = screen.getByTestId("target-input");
expect(targetInput).toHaveValue("");
await userEvent.click(targetInput);
expect(screen.getByText("file1")).toBeInTheDocument();
expect(screen.getByText("file2")).toBeInTheDocument();
await userEvent.click(screen.getByText("file1"));
expect(targetInput).toHaveValue("file1");
});
it("should call onLaunch with the form data", async () => {
renderMicroagentModal();
const triggerInput = screen.getByTestId("trigger-input");
await userEvent.type(triggerInput, "trigger1 ");
await userEvent.type(triggerInput, "trigger2 ");
const targetInput = screen.getByTestId("target-input");
await userEvent.click(targetInput);
await userEvent.click(screen.getByText("file1"));
const launchButton = await screen.findByRole("button", { name: "Launch" });
await userEvent.click(launchButton);
expect(onLaunchMock).toHaveBeenCalledWith("Generated prompt", "file1", [
"trigger1",
"trigger2",
]);
});
it("should disable the launch button if isLoading is true", async () => {
renderMicroagentModal({ isLoading: true });
const launchButton = screen.getByRole("button", { name: "Launch" });
expect(launchButton).toBeDisabled();
});
});
@@ -0,0 +1,107 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Messages } from "#/components/features/chat/messages";
import {
AssistantMessageAction,
OpenHandsAction,
UserMessageAction,
} from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import OpenHands from "#/api/open-hands";
import { Conversation } from "#/api/open-hands.types";
vi.mock("react-router", () => ({
useParams: () => ({ conversationId: "123" }),
}));
let queryClient: QueryClient;
const renderMessages = ({
messages,
}: {
messages: (OpenHandsAction | OpenHandsObservation)[];
}) => {
const { rerender, ...rest } = render(
<Messages messages={messages} isAwaitingUserConfirmation={false} />,
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient!}>
{children}
</QueryClientProvider>
),
},
);
const rerenderMessages = (
newMessages: (OpenHandsAction | OpenHandsObservation)[],
) => {
rerender(
<Messages messages={newMessages} isAwaitingUserConfirmation={false} />,
);
};
return { ...rest, rerender: rerenderMessages };
};
describe("Messages", () => {
beforeEach(() => {
queryClient = new QueryClient();
});
const assistantMessage: AssistantMessageAction = {
id: 0,
action: "message",
source: "agent",
message: "Hello, Assistant!",
timestamp: new Date().toISOString(),
args: {
image_urls: [],
file_urls: [],
thought: "",
wait_for_response: false,
},
};
const userMessage: UserMessageAction = {
id: 1,
action: "message",
source: "user",
message: "Hello, User!",
timestamp: new Date().toISOString(),
args: { content: "Hello, User!", image_urls: [], file_urls: [] },
};
it("should render", () => {
renderMessages({ messages: [userMessage, assistantMessage] });
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
});
it("should render a launch to microagent action button on chat messages only if it is a user message", () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
const mockConversation: Conversation = {
conversation_id: "123",
title: "Test Conversation",
status: "RUNNING",
runtime_status: "STATUS$READY",
created_at: new Date().toISOString(),
last_updated_at: new Date().toISOString(),
selected_branch: null,
selected_repository: null,
git_provider: "github",
session_api_key: null,
url: null,
};
getConversationSpy.mockResolvedValue(mockConversation);
renderMessages({
messages: [userMessage, assistantMessage],
});
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
});
});
@@ -27,9 +27,9 @@ vi.mock("react-i18next", async () => {
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"CONVERSATION$CREATED": "Created",
"CONVERSATION$AGO": "ago",
"CONVERSATION$UPDATED": "Updated"
CONVERSATION$CREATED: "Created",
CONVERSATION$AGO: "ago",
CONVERSATION$UPDATED: "Updated",
};
return translations[key] || key;
},
@@ -82,7 +82,9 @@ describe("ConversationCard", () => {
expect(card).toHaveTextContent("ago");
// Use a regex to match the time part since it might have whitespace
const timeRegex = new RegExp(formatTimeDelta(new Date("2021-10-01T12:00:00Z")));
const timeRegex = new RegExp(
formatTimeDelta(new Date("2021-10-01T12:00:00Z")),
);
expect(card).toHaveTextContent(timeRegex);
});
@@ -108,7 +110,11 @@ describe("ConversationCard", () => {
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository="org/selectedRepository"
selectedRepository={{
selected_repository: "org/selectedRepository",
selected_branch: "main",
git_provider: "github",
}}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
@@ -173,7 +179,11 @@ describe("ConversationCard", () => {
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository="org/selectedRepository"
selectedRepository={{
selected_repository: "org/selectedRepository",
selected_branch: "main",
git_provider: "github",
}}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
@@ -529,4 +529,287 @@ describe("ConversationPanel", () => {
expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
});
it("should show edit button in context menu", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Edit button should be visible
const editButton = screen.getByTestId("edit-button");
expect(editButton).toBeInTheDocument();
expect(editButton).toHaveTextContent("BUTTON$EDIT_TITLE");
});
it("should enter edit mode when edit button is clicked", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Click edit button
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Should find input field instead of title text
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
expect(titleInput).toBeInTheDocument();
expect(titleInput.tagName).toBe("INPUT");
expect(titleInput).toHaveValue("Conversation 1");
expect(titleInput).toHaveFocus();
});
it("should successfully update conversation title", async () => {
const user = userEvent.setup();
// Mock the updateConversation API call
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
// Mock the toast function
const mockToast = vi.fn();
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: mockToast,
}));
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Updated Title");
// Blur the input to save
await user.tab();
// Verify API call was made with correct parameters
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Updated Title",
});
});
it("should save title when Enter key is pressed", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title and press Enter
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Title Updated via Enter");
await user.keyboard("{Enter}");
// Verify API call was made
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Title Updated via Enter",
});
});
it("should trim whitespace from title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title with extra whitespace
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, " Trimmed Title ");
await user.tab();
// Verify API call was made with trimmed title
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Trimmed Title",
});
// Verify input shows trimmed value
expect(titleInput).toHaveValue("Trimmed Title");
});
it("should revert to original title when empty", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Clear the title completely
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.tab();
// Verify API was not called
expect(updateConversationSpy).not.toHaveBeenCalled();
// Verify input reverted to original value
expect(titleInput).toHaveValue("Conversation 1");
});
it("should handle API error when updating title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockRejectedValue(new Error("API Error"));
vi.mock("#/utils/custom-toast-handlers", () => ({
displayErrorToast: vi.fn(),
}));
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Failed Update");
await user.tab();
// Verify API call was made
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Failed Update",
});
// Wait for error handling
await waitFor(() => {
expect(updateConversationSpy).toHaveBeenCalled();
});
});
it("should close context menu when edit button is clicked", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Verify context menu is open
const contextMenu = screen.getByTestId("context-menu");
expect(contextMenu).toBeInTheDocument();
// Click edit button
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Verify context menu is closed
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
});
it("should not call API when title is unchanged", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Don't change the title, just blur
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.tab();
// Verify API was called with the same title (since handleConversationTitleChange will always be called)
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Conversation 1",
});
});
it("should handle special characters in title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title with special characters
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Special @#$%^&*()_+ Characters");
await user.tab();
// Verify API call was made with special characters
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Special @#$%^&*()_+ Characters",
});
});
});
@@ -17,12 +17,12 @@ vi.mock("react-i18next", async () => {
t: (key: string) => {
// Return a mock translation for the test
const translations: Record<string, string> = {
"HOME$LETS_START_BUILDING": "Let's start building",
"HOME$LAUNCH_FROM_SCRATCH": "Launch from Scratch",
"HOME$LOADING": "Loading...",
"HOME$OPENHANDS_DESCRIPTION": "OpenHands is an AI software engineer",
"HOME$NOT_SURE_HOW_TO_START": "Not sure how to start?",
"HOME$READ_THIS": "Read this"
HOME$LETS_START_BUILDING: "Let's start building",
HOME$LAUNCH_FROM_SCRATCH: "Launch from Scratch",
HOME$LOADING: "Loading...",
HOME$OPENHANDS_DESCRIPTION: "OpenHands is an AI software engineer",
HOME$NOT_SURE_HOW_TO_START: "Not sure how to start?",
HOME$READ_THIS: "Read this",
};
return translations[key] || key;
},
@@ -69,7 +69,6 @@ describe("HomeHeader", () => {
undefined,
undefined,
undefined,
[],
undefined,
undefined,
undefined,
@@ -119,18 +119,48 @@ describe("RepoConnector", () => {
expect(launchButton).toBeEnabled();
});
it("should render the 'add git(hub|lab) repos' links if saas mode", async () => {
it("should render the 'add github repos' link if saas mode and github provider is set", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "some-token",
gitlab: null,
},
});
renderRepoConnector();
await screen.findByText("HOME$ADD_GITHUB_REPOS");
});
it("should not render the 'add github repos' link if github provider is not set", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
gitlab: "some-token",
github: null,
},
});
renderRepoConnector();
expect(screen.queryByText("HOME$ADD_GITHUB_REPOS")).not.toBeInTheDocument();
});
it("should not render the 'add git(hub|lab) repos' links if oss mode", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
@@ -176,9 +206,8 @@ describe("RepoConnector", () => {
"rbren/polaris",
"github",
undefined,
[],
undefined,
undefined,
"main",
undefined,
);
});
@@ -66,6 +66,11 @@ vi.mock("#/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
vi.mock("react-router", async (importActual) => ({
...(await importActual()),
useNavigate: vi.fn(),
}));
const mockOnRepoSelection = vi.fn();
const renderForm = () =>
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
@@ -252,8 +257,6 @@ describe("RepositorySelectionForm", () => {
expect(searchedRepo).toBeInTheDocument();
await userEvent.click(searchedRepo);
expect(mockOnRepoSelection).toHaveBeenCalledWith(
MOCK_SEARCH_REPOS[0].full_name,
);
expect(mockOnRepoSelection).toHaveBeenCalledWith(MOCK_SEARCH_REPOS[0]);
});
});
@@ -88,9 +88,14 @@ describe("TaskCard", () => {
MOCK_RESPOSITORIES[0].full_name,
MOCK_RESPOSITORIES[0].git_provider,
undefined,
[],
{
git_provider: "github",
issue_number: 123,
repo: "repo1",
task_type: "MERGE_CONFLICTS",
title: "Task 1",
},
undefined,
MOCK_TASK_1,
undefined,
);
});
@@ -105,4 +110,29 @@ describe("TaskCard", () => {
expect(launchButton).toHaveTextContent(/Loading/i);
expect(launchButton).toBeDisabled();
});
it("should navigate to the conversation page after creating a conversation", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
createConversationSpy.mockResolvedValue({
conversation_id: "test-conversation-id",
title: "Test Conversation",
selected_repository: "repo1",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2023-01-01T00:00:00Z",
created_at: "2023-01-01T00:00:00Z",
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null
});
renderTaskCard();
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
// Wait for navigation to the conversation page
await screen.findByTestId("conversation-screen");
});
});
@@ -0,0 +1,84 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderWithProviders } from "test-utils";
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
import OpenHands from "#/api/open-hands";
import { AgentState } from "#/types/agent-state";
vi.mock("react-redux", async () => {
const actual = await vi.importActual("react-redux");
return {
...actual,
useDispatch: () => vi.fn(),
useSelector: () => ({
agent: {
curAgentState: AgentState.AWAITING_USER_INPUT,
},
}),
};
});
describe("MicroagentsModal - Refresh Button", () => {
const mockOnClose = vi.fn();
const conversationId = "test-conversation-id";
const defaultProps = {
onClose: mockOnClose,
conversationId,
};
const mockMicroagents = [
{
name: "Test Agent 1",
type: "repo" as const,
triggers: ["test", "example"],
content: "This is test content for agent 1",
},
{
name: "Test Agent 2",
type: "knowledge" as const,
triggers: ["help", "support"],
content: "This is test content for agent 2",
},
];
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
// Setup default mock for getUserConversations
vi.spyOn(OpenHands, "getMicroagents").mockResolvedValue({
microagents: mockMicroagents,
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Refresh Button Rendering", () => {
it("should render the refresh button with correct text and test ID", () => {
renderWithProviders(<MicroagentsModal {...defaultProps} />);
const refreshButton = screen.getByTestId("refresh-microagents");
expect(refreshButton).toBeInTheDocument();
expect(refreshButton).toHaveTextContent("BUTTON$REFRESH");
});
});
describe("Refresh Button Functionality", () => {
it("should call refetch when refresh button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<MicroagentsModal {...defaultProps} />);
const refreshSpy = vi.spyOn(OpenHands, "getMicroagents");
const refreshButton = screen.getByTestId("refresh-microagents");
await user.click(refreshButton);
expect(refreshSpy).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,62 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
describe("BadgeInput", () => {
it("should render the values", () => {
const onChangeMock = vi.fn();
render(<BadgeInput value={["test", "test2"]} onChange={onChangeMock} />);
expect(screen.getByText("test")).toBeInTheDocument();
expect(screen.getByText("test2")).toBeInTheDocument();
});
it("should render the input's as a badge on space", async () => {
const onChangeMock = vi.fn();
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
const input = screen.getByTestId("badge-input");
expect(input).toHaveValue("");
await userEvent.type(input, "test");
await userEvent.type(input, " ");
expect(onChangeMock).toHaveBeenCalledWith(["badge1", "test"]);
expect(input).toHaveValue("");
});
it("should remove the badge on backspace", async () => {
const onChangeMock = vi.fn();
render(<BadgeInput value={["badge1", "badge2"]} onChange={onChangeMock} />);
const input = screen.getByTestId("badge-input");
expect(input).toHaveValue("");
await userEvent.type(input, "{backspace}");
expect(onChangeMock).toHaveBeenCalledWith(["badge1"]);
expect(input).toHaveValue("");
});
it("should remove the badge on click", async () => {
const onChangeMock = vi.fn();
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
const removeButton = screen.getByTestId("remove-button");
await userEvent.click(removeButton);
expect(onChangeMock).toHaveBeenCalledWith([]);
});
it("should not create empty badges", async () => {
const onChangeMock = vi.fn();
render(<BadgeInput value={[]} onChange={onChangeMock} />);
const input = screen.getByTestId("badge-input");
expect(input).toHaveValue("");
await userEvent.type(input, " ");
expect(onChangeMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,140 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
// Mock the useConfig hook
vi.mock("#/hooks/query/use-config", () => ({
useConfig: vi.fn(),
}));
// Mock the useConversationId hook
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "test-conversation-id" }),
}));
describe("useFeedbackExists", () => {
let queryClient: QueryClient;
const mockCheckFeedbackExists = vi.spyOn(OpenHands, "checkFeedbackExists");
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockCheckFeedbackExists.mockClear();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
it("should not call API when APP_MODE is not saas", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "oss" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
it("should call API when APP_MODE is saas", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
mockCheckFeedbackExists.mockResolvedValue({
exists: true,
rating: 5,
reason: "Great job!",
});
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for the query to complete
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was called
expect(mockCheckFeedbackExists).toHaveBeenCalledWith(
"test-conversation-id",
123,
);
// Verify that the data is returned
expect(result.current.data).toEqual({
exists: true,
rating: 5,
reason: "Great job!",
});
});
it("should not call API when eventId is not provided", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(undefined), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
it("should not call API when config is not loaded yet", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
});
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MicroagentStatusIndicator } from "#/components/features/chat/microagent/microagent-status-indicator";
import { MicroagentStatus } from "#/types/microagent-status";
// Mock the translation hook
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("MicroagentStatusIndicator", () => {
it("should show 'View your PR' when status is completed and PR URL is provided", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
conversationId="test-conversation"
prUrl="https://github.com/owner/repo/pull/123"
/>,
);
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
"href",
"https://github.com/owner/repo/pull/123",
);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("should show default completed message when status is completed but no PR URL", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
conversationId="test-conversation"
/>,
);
const link = screen.getByRole("link", {
name: "MICROAGENT$STATUS_COMPLETED",
});
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/conversations/test-conversation");
});
it("should show creating status without PR URL", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.CREATING}
conversationId="test-conversation"
/>,
);
expect(screen.getByText("MICROAGENT$STATUS_CREATING")).toBeInTheDocument();
});
it("should show error status", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.ERROR}
conversationId="test-conversation"
/>,
);
expect(screen.getByText("MICROAGENT$STATUS_ERROR")).toBeInTheDocument();
});
it("should prioritize PR URL over conversation link when both are provided", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
conversationId="test-conversation"
prUrl="https://github.com/owner/repo/pull/123"
/>,
);
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
expect(link).toHaveAttribute(
"href",
"https://github.com/owner/repo/pull/123",
);
// Should not link to conversation when PR URL is available
expect(link).not.toHaveAttribute(
"href",
"/conversations/test-conversation",
);
});
it("should work with GitLab MR URLs", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
prUrl="https://gitlab.com/owner/repo/-/merge_requests/456"
/>,
);
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
expect(link).toHaveAttribute(
"href",
"https://gitlab.com/owner/repo/-/merge_requests/456",
);
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect } from "vitest";
import {
extractPRUrls,
containsPRUrl,
getFirstPRUrl,
} from "#/utils/parse-pr-url";
describe("parse-pr-url", () => {
describe("extractPRUrls", () => {
it("should extract GitHub PR URLs", () => {
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
const urls = extractPRUrls(text);
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
});
it("should extract GitLab MR URLs", () => {
const text =
"Merge request: https://gitlab.com/owner/repo/-/merge_requests/456";
const urls = extractPRUrls(text);
expect(urls).toEqual([
"https://gitlab.com/owner/repo/-/merge_requests/456",
]);
});
it("should extract Bitbucket PR URLs", () => {
const text =
"PR link: https://bitbucket.org/owner/repo/pull-requests/789";
const urls = extractPRUrls(text);
expect(urls).toEqual([
"https://bitbucket.org/owner/repo/pull-requests/789",
]);
});
it("should extract Azure DevOps PR URLs", () => {
const text =
"Azure PR: https://dev.azure.com/org/project/_git/repo/pullrequest/101";
const urls = extractPRUrls(text);
expect(urls).toEqual([
"https://dev.azure.com/org/project/_git/repo/pullrequest/101",
]);
});
it("should extract multiple PR URLs", () => {
const text = `
GitHub: https://github.com/owner/repo/pull/123
GitLab: https://gitlab.com/owner/repo/-/merge_requests/456
`;
const urls = extractPRUrls(text);
expect(urls).toHaveLength(2);
expect(urls).toContain("https://github.com/owner/repo/pull/123");
expect(urls).toContain(
"https://gitlab.com/owner/repo/-/merge_requests/456",
);
});
it("should handle self-hosted GitLab URLs", () => {
const text =
"Self-hosted: https://gitlab.example.com/owner/repo/-/merge_requests/123";
const urls = extractPRUrls(text);
expect(urls).toEqual([
"https://gitlab.example.com/owner/repo/-/merge_requests/123",
]);
});
it("should return empty array when no PR URLs found", () => {
const text = "This is just regular text with no PR URLs";
const urls = extractPRUrls(text);
expect(urls).toEqual([]);
});
it("should handle URLs with HTTP instead of HTTPS", () => {
const text = "HTTP PR: http://github.com/owner/repo/pull/123";
const urls = extractPRUrls(text);
expect(urls).toEqual(["http://github.com/owner/repo/pull/123"]);
});
it("should remove duplicate URLs", () => {
const text = `
Same PR mentioned twice:
https://github.com/owner/repo/pull/123
https://github.com/owner/repo/pull/123
`;
const urls = extractPRUrls(text);
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
});
});
describe("containsPRUrl", () => {
it("should return true when PR URL is present", () => {
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
expect(containsPRUrl(text)).toBe(true);
});
it("should return false when no PR URL is present", () => {
const text = "This is just regular text";
expect(containsPRUrl(text)).toBe(false);
});
});
describe("getFirstPRUrl", () => {
it("should return the first PR URL found", () => {
const text = `
First: https://github.com/owner/repo/pull/123
Second: https://gitlab.com/owner/repo/-/merge_requests/456
`;
const url = getFirstPRUrl(text);
expect(url).toBe("https://github.com/owner/repo/pull/123");
});
it("should return null when no PR URL is found", () => {
const text = "This is just regular text";
const url = getFirstPRUrl(text);
expect(url).toBeNull();
});
});
describe("real-world scenarios", () => {
it("should handle typical microagent finish messages", () => {
const text = `
I have successfully created a pull request with the requested changes.
You can view the PR here: https://github.com/All-Hands-AI/OpenHands/pull/1234
The changes include:
- Updated the component
- Added tests
- Fixed the issue
`;
const url = getFirstPRUrl(text);
expect(url).toBe("https://github.com/All-Hands-AI/OpenHands/pull/1234");
});
it("should handle messages with PR URLs in the middle", () => {
const text = `
Task completed successfully! I've created a pull request at
https://github.com/owner/repo/pull/567 with all the requested changes.
Please review when you have a chance.
`;
const url = getFirstPRUrl(text);
expect(url).toBe("https://github.com/owner/repo/pull/567");
});
});
});
+53 -22
View File
@@ -1,8 +1,8 @@
import { render, screen, within } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import SettingsScreen from "#/routes/settings";
import { QueryClientProvider } from "@tanstack/react-query";
import SettingsScreen, { clientLoader } from "#/routes/settings";
import OpenHands from "#/api/open-hands";
// Mock the i18next hook
@@ -31,16 +31,27 @@ vi.mock("react-i18next", async () => {
});
describe("Settings Screen", () => {
const { handleLogoutMock } = vi.hoisted(() => ({
const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({
handleLogoutMock: vi.fn(),
mockQueryClient: (() => {
const { QueryClient } = require("@tanstack/react-query");
return new QueryClient();
})(),
}));
vi.mock("#/hooks/use-app-logout", () => ({
useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }),
}));
vi.mock("#/query-client-config", () => ({
queryClient: mockQueryClient,
}));
const RouterStub = createRoutesStub([
{
Component: SettingsScreen,
// @ts-expect-error - custom loader
clientLoader,
path: "/settings",
children: [
{
@@ -56,8 +67,8 @@ describe("Settings Screen", () => {
path: "/settings/app",
},
{
Component: () => <div data-testid="credits-settings-screen" />,
path: "/settings/credits",
Component: () => <div data-testid="billing-settings-screen" />,
path: "/settings/billing",
},
{
Component: () => <div data-testid="api-keys-settings-screen" />,
@@ -67,26 +78,27 @@ describe("Settings Screen", () => {
},
]);
const renderSettingsScreen = (path = "/settings") => {
const queryClient = new QueryClient();
return render(<RouterStub initialEntries={[path]} />, {
const renderSettingsScreen = (path = "/settings") =>
render(<RouterStub initialEntries={[path]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
});
};
it("should render the navbar", async () => {
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
const sectionsToExclude = ["api keys", "credits"];
const sectionsToExclude = ["api keys", "credits", "billing"];
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
// Clear any existing query data
mockQueryClient.clear();
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
@@ -102,6 +114,8 @@ describe("Settings Screen", () => {
});
expect(sectionElement).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should render the saas navbar", async () => {
@@ -113,12 +127,15 @@ describe("Settings Screen", () => {
const sectionsToInclude = [
"integrations",
"application",
"credits",
"credits", // The nav item shows "credits" text but routes to /billing
"secrets",
"api keys",
];
const sectionsToExclude = ["llm"];
// Clear any existing query data
mockQueryClient.clear();
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
@@ -134,30 +151,44 @@ describe("Settings Screen", () => {
});
expect(sectionElement).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should not be able to access oss-restricted routes in oss", async () => {
it("should not be able to access saas-only routes in oss mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
const { rerender } = renderSettingsScreen("/settings/credits");
// Clear any existing query data
mockQueryClient.clear();
// In OSS mode, accessing restricted routes should redirect to /settings
// Since createRoutesStub doesn't handle clientLoader redirects properly,
// we test that the correct navbar is shown (OSS navbar) and that
// the restricted route components are not rendered when accessing /settings
renderSettingsScreen("/settings");
// Verify we're in OSS mode by checking the navbar
const navbar = await screen.findByTestId("settings-navbar");
expect(within(navbar).getByText("LLM")).toBeInTheDocument();
expect(
screen.queryByTestId("credits-settings-screen"),
within(navbar).queryByText("credits", { exact: false }),
).not.toBeInTheDocument();
rerender(<RouterStub initialEntries={["/settings/api-keys"]} />);
expect(
screen.queryByTestId("api-keys-settings-screen"),
).not.toBeInTheDocument();
rerender(<RouterStub initialEntries={["/settings/billing"]} />);
// Verify the LLM settings screen is shown
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
expect(
screen.queryByTestId("billing-settings-screen"),
).not.toBeInTheDocument();
rerender(<RouterStub initialEntries={["/settings"]} />);
expect(
screen.queryByTestId("api-keys-settings-screen"),
).not.toBeInTheDocument();
getConfigSpy.mockRestore();
});
it.todo("should not be able to access saas-restricted routes in saas");
it.todo("should not be able to access oss-only routes in saas mode");
});
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import ActionType from "#/types/action-type";
import { ActionMessage } from "#/types/message";
// Mock the store and actions
const mockDispatch = vi.fn();
const mockAppendInput = vi.fn();
const mockAppendJupyterInput = vi.fn();
vi.mock("#/store", () => ({
default: {
dispatch: mockDispatch,
},
}));
vi.mock("#/state/command-slice", () => ({
appendInput: mockAppendInput,
}));
vi.mock("#/state/jupyter-slice", () => ({
appendJupyterInput: mockAppendJupyterInput,
}));
describe("handleActionMessage", () => {
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks();
});
it("should handle RUN actions by adding input to terminal", async () => {
const { handleActionMessage } = await import("#/services/actions");
const runAction: ActionMessage = {
id: 1,
source: "agent",
action: ActionType.RUN,
args: {
command: "ls -la",
},
message: "Running command: ls -la",
timestamp: "2023-01-01T00:00:00Z",
};
// Handle the action
handleActionMessage(runAction);
// Check that appendInput was called with the command
expect(mockDispatch).toHaveBeenCalledWith(mockAppendInput("ls -la"));
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
});
it("should handle RUN_IPYTHON actions by adding input to Jupyter", async () => {
const { handleActionMessage } = await import("#/services/actions");
const ipythonAction: ActionMessage = {
id: 2,
source: "agent",
action: ActionType.RUN_IPYTHON,
args: {
code: "print('Hello from Jupyter!')",
},
message: "Running Python code interactively: print('Hello from Jupyter!')",
timestamp: "2023-01-01T00:00:00Z",
};
// Handle the action
handleActionMessage(ipythonAction);
// Check that appendJupyterInput was called with the code
expect(mockDispatch).toHaveBeenCalledWith(mockAppendJupyterInput("print('Hello from Jupyter!')"));
expect(mockAppendInput).not.toHaveBeenCalled();
});
it("should not process hidden actions", async () => {
const { handleActionMessage } = await import("#/services/actions");
const hiddenAction: ActionMessage = {
id: 3,
source: "agent",
action: ActionType.RUN,
args: {
command: "secret command",
hidden: "true",
},
message: "Running command: secret command",
timestamp: "2023-01-01T00:00:00Z",
};
// Handle the action
handleActionMessage(hiddenAction);
// Check that nothing was dispatched
expect(mockDispatch).not.toHaveBeenCalled();
});
});
@@ -1,42 +0,0 @@
import { render } from "@testing-library/react";
import { test, expect, describe, vi } from "vitest";
import { HomeHeader } from "#/components/features/home/home-header";
// Mock dependencies
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
useCreateConversation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
}),
}));
vi.mock("#/hooks/use-is-creating-conversation", () => ({
useIsCreatingConversation: () => false,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("Check for hardcoded English strings in Home components", () => {
test("HomeHeader should not have hardcoded English strings", () => {
const { container } = render(<HomeHeader />);
// Get all text content
const text = container.textContent;
// List of English strings that should be translated
const hardcodedStrings = [
"Launch from Scratch",
"Read this",
];
// Check each string
hardcodedStrings.forEach((str) => {
expect(text).not.toContain(str);
});
});
});
@@ -82,17 +82,5 @@ describe("extractModelAndProvider", () => {
model: "claude-opus-4-20250514",
separator: "/",
});
expect(extractModelAndProvider("claude-3-haiku-20240307")).toEqual({
provider: "anthropic",
model: "claude-3-haiku-20240307",
separator: "/",
});
expect(extractModelAndProvider("claude-2.1")).toEqual({
provider: "anthropic",
model: "claude-2.1",
separator: "/",
});
});
});
@@ -52,14 +52,16 @@ test("organizeModelsAndProviders", () => {
separator: "/",
models: [
"claude-3-5-sonnet-20241022",
],
},
other: {
separator: "",
models: [
"together-ai-21.1b-41b",
"claude-3-haiku-20240307",
"claude-2",
"claude-2.1",
],
},
other: {
separator: "",
models: ["together-ai-21.1b-41b"],
},
});
});
+1169 -1383
View File
File diff suppressed because it is too large Load Diff
+24 -23
View File
@@ -1,55 +1,56 @@
{
"name": "openhands-frontend",
"version": "0.48.0",
"version": "0.49.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
},
"dependencies": {
"@heroui/react": "^2.8.0-beta.10",
"@heroui/react": "^2.8.1",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.6.3",
"@react-router/serve": "^7.6.3",
"@react-router/node": "^7.7.0",
"@react-router/serve": "^7.7.0",
"@react-types/shared": "^3.29.1",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.4.0",
"@stripe/stripe-js": "^7.5.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.81.4",
"@vitejs/plugin-react": "^4.6.0",
"@tanstack/react-query": "^5.83.0",
"@vitejs/plugin-react": "^4.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.10.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.22.0",
"i18next": "^25.3.0",
"framer-motion": "^12.23.6",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.28",
"jose": "^6.0.11",
"jose": "^6.0.12",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.256.0",
"posthog-js": "^1.257.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.5.3",
"react-i18next": "^15.6.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.6.3",
"react-router": "^7.7.0",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"vite": "^7.0.0",
"vite": "^7.0.5",
"web-vitals": "^5.0.3",
"ws": "^8.18.2"
},
@@ -70,7 +71,6 @@
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
"prepare": "cd .. && husky frontend/.husky",
"typecheck": "react-router typegen && tsc",
"check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs",
"check-translation-completeness": "node scripts/check-translation-completeness.cjs"
},
"lint-staged": {
@@ -80,19 +80,19 @@
]
},
"devDependencies": {
"@babel/parser": "^7.27.7",
"@babel/traverse": "^7.27.7",
"@babel/types": "^7.27.0",
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.1",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.53.2",
"@react-router/dev": "^7.6.3",
"@playwright/test": "^1.54.1",
"@react-router/dev": "^7.7.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.8",
"@types/node": "^24.0.14",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -107,6 +107,7 @@
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-i18next": "^6.1.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.1",
@@ -118,7 +119,7 @@
"lint-staged": "^16.1.2",
"msw": "^2.6.6",
"prettier": "^3.6.2",
"stripe": "^18.2.1",
"stripe": "^18.3.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3",
"vite-plugin-svgr": "^4.2.0",
+1 -1
View File
@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.10.2'
const PACKAGE_VERSION = '2.10.3'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
@@ -1,739 +0,0 @@
#!/usr/bin/env node
/**
* Pre-commit hook script to check for unlocalized strings in the frontend code
* This script is based on the test in __tests__/utils/check-hardcoded-strings.test.tsx
*/
const path = require('path');
const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// Files/directories to ignore
const IGNORE_PATHS = [
// Build and dependency files
"node_modules",
"dist",
".git",
"test",
"__tests__",
".d.ts",
"i18n",
"package.json",
"package-lock.json",
"tsconfig.json",
// Internal code that doesn't need localization
"mocks", // Mock data
"assets", // SVG paths and CSS classes
"types", // Type definitions and constants
"state", // Redux state management
"api", // API endpoints
"services", // Internal services
"hooks", // React hooks
"context", // React context
"store", // Redux store
"routes.ts", // Route definitions
"root.tsx", // Root component
"entry.client.tsx", // Client entry point
"utils/scan-unlocalized-strings.ts", // Original scanner
"utils/scan-unlocalized-strings-ast.ts", // This file itself
"frontend/src/components/features/home/tasks/get-prompt-for-query.ts", // Only contains agent prompts
];
// Extensions to scan
const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
// Attributes that typically don't contain user-facing text
const NON_TEXT_ATTRIBUTES = [
"allow",
"className",
"i18nKey",
"testId",
"id",
"name",
"type",
"href",
"src",
"rel",
"target",
"style",
"onClick",
"onChange",
"onSubmit",
"data-testid",
"aria-labelledby",
"aria-describedby",
"aria-hidden",
"role",
"sandbox",
];
function shouldIgnorePath(filePath) {
return IGNORE_PATHS.some((ignore) => filePath.includes(ignore));
}
// Check if a string looks like a translation key
// Translation keys typically use dots, underscores, or are all caps
// Also check for the pattern with $ which is used in our translation keys
function isLikelyTranslationKey(str) {
return (
/^[A-Z0-9_$.]+$/.test(str) ||
str.includes(".") ||
/[A-Z0-9_]+\$[A-Z0-9_]+/.test(str)
);
}
// Check if a string is a raw translation key that should be wrapped in t()
function isRawTranslationKey(str) {
// Check for our specific translation key pattern (e.g., "SETTINGS$GITHUB_SETTINGS")
// Exclude specific keys that are already properly used with i18next.t() in the code
const excludedKeys = [
"STATUS$ERROR_LLM_OUT_OF_CREDITS",
"ERROR$GENERIC",
"GITHUB$AUTH_SCOPE",
];
if (excludedKeys.includes(str)) {
return false;
}
return /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str);
}
// Specific technical strings that should be excluded from localization
const EXCLUDED_TECHNICAL_STRINGS = [
"openid email profile", // OAuth scope string - not user-facing
"OPEN_ISSUE", // Task type identifier, not a UI string
"Merge Request", // Git provider specific terminology
"GitLab API", // Git provider specific terminology
"Pull Request", // Git provider specific terminology
"GitHub API", // Git provider specific terminology
"add-secret-form", // Test ID for secret form
"edit-secret-form", // Test ID for secret form
"search-api-key-input", // Input name for search API key
"noopener,noreferrer", // Options for window.open
"STATUS$READY",
"STATUS$STOPPED",
"STATUS$ERROR",
];
function isExcludedTechnicalString(str) {
return EXCLUDED_TECHNICAL_STRINGS.includes(str);
}
function isLikelyCode(str) {
// A string with no spaces and at least one underscore or colon is likely a code.
// (e.g.: "browser_interactive" or "error:")
if (str.includes(" ")) {
return false
}
if (str.includes(":") || str.includes("_")){
return true
}
return false
}
function isCommonDevelopmentString(str) {
// Technical patterns that are definitely not UI strings
const technicalPatterns = [
// URLs and paths
/^https?:\/\//, // URLs
/^\/[a-zA-Z0-9_\-./]*$/, // File paths
/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/, // File extensions, class names
/^@[a-zA-Z0-9/-]+$/, // Import paths
/^#\/[a-zA-Z0-9/-]+$/, // Alias imports
/^[a-zA-Z0-9/-]+\/[a-zA-Z0-9/-]+$/, // Module paths
/^data:image\/[a-zA-Z0-9;,]+$/, // Data URLs
/^application\/[a-zA-Z0-9-]+$/, // MIME types
/^!\[image]\(data:image\/png;base64,$/, // Markdown image with base64 data
// Numbers, IDs, and technical values
/^\d+(\.\d+)?$/, // Numbers
/^#[0-9a-fA-F]{3,8}$/, // Color codes
/^[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+$/, // Key-value pairs
/^mm:ss$/, // Time format
/^[a-zA-Z0-9]+\/[a-zA-Z0-9-]+$/, // Provider/model format
/^\?[a-zA-Z0-9_-]+$/, // URL parameters
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, // UUID
/^[A-Za-z0-9+/=]+$/, // Base64
// HTML and CSS selectors
/^[a-z]+(\[[^\]]+\])+$/, // CSS attribute selectors
/^[a-z]+:[a-z-]+$/, // CSS pseudo-selectors
/^[a-z]+\.[a-z0-9_-]+$/, // CSS class selectors
/^[a-z]+#[a-z0-9_-]+$/, // CSS ID selectors
/^[a-z]+\s*>\s*[a-z]+$/, // CSS child selectors
/^[a-z]+\s+[a-z]+$/, // CSS descendant selectors
// CSS and styling patterns
/^[a-z0-9-]+:[a-z0-9-]+$/, // CSS property:value
/^[a-z0-9-]+:[a-z0-9-]+;[a-z0-9-]+:[a-z0-9-]+$/, // Multiple CSS properties
];
// File extensions and media types
const fileExtensionPattern =
/^\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|pdf|mp4|webm|ogg|mp3|wav|json|xml|csv|txt|md|html|css|js|jsx|ts|tsx)$/i;
if (fileExtensionPattern.test(str)) {
return true;
}
// AI model and provider patterns
const aiRelatedPattern =
/^(AI|OpenAI|VertexAI|PaLM|Gemini|Anthropic|Anyscale|Databricks|Ollama|FriendliAI|Groq|DeepInfra|AI21|Replicate|OpenRouter|Azure|AWS|SageMaker|Bedrock|Mistral|Perplexity|Fireworks|Cloudflare|Workers|Voyage|claude-|gpt-|o1-|o3-)/i;
if (aiRelatedPattern.test(str)) {
return true;
}
// CSS units and values
const cssUnitsPattern =
/\b\d+(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$|^(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$/;
const cssValuesPattern =
/(rgb|rgba|hsl|hsla|#[0-9a-fA-F]+|solid|absolute|relative|sticky|fixed|static|block|inline|flex|grid|none|auto|hidden|visible)/;
if (cssUnitsPattern.test(str) || cssValuesPattern.test(str)) {
return true;
}
// Check for CSS class strings with brackets (common in the codebase)
if (
str.includes("[") &&
str.includes("]") &&
(str.includes("px") ||
str.includes("rem") ||
str.includes("em") ||
str.includes("w-") ||
str.includes("h-") ||
str.includes("p-") ||
str.includes("m-"))
) {
return true;
}
// Check for CSS class strings with specific patterns
if (
str.includes("border-") ||
str.includes("rounded-") ||
str.includes("cursor-") ||
str.includes("opacity-") ||
str.includes("disabled:") ||
str.includes("hover:") ||
str.includes("focus-within:") ||
str.includes("first-of-type:") ||
str.includes("last-of-type:") ||
str.includes("group-data-")
) {
return true;
}
// Check if it looks like a Tailwind class string
if (/^[a-z0-9-]+(\s+[a-z0-9-]+)*$/.test(str)) {
// Common Tailwind prefixes and patterns
const tailwindPrefixes = [
"bg-", "text-", "border-", "rounded-", "p-", "m-", "px-", "py-", "mx-", "my-",
"w-", "h-", "min-w-", "min-h-", "max-w-", "max-h-", "flex-", "grid-", "gap-",
"space-", "items-", "justify-", "self-", "col-", "row-", "order-", "object-",
"overflow-", "opacity-", "z-", "top-", "right-", "bottom-", "left-", "inset-",
"font-", "tracking-", "leading-", "list-", "placeholder-", "shadow-", "ring-",
"transition-", "duration-", "ease-", "delay-", "animate-", "scale-", "rotate-",
"translate-", "skew-", "origin-", "cursor-", "select-", "resize-", "fill-", "stroke-",
];
// Check if any word in the string starts with a Tailwind prefix
const words = str.split(/\s+/);
for (const word of words) {
for (const prefix of tailwindPrefixes) {
if (word.startsWith(prefix)) {
return true;
}
}
}
// Check for Tailwind modifiers
const tailwindModifiers = [
"hover:", "focus:", "active:", "disabled:", "visited:", "first:", "last:",
"odd:", "even:", "group-hover:", "focus-within:", "focus-visible:", "motion-safe:",
"motion-reduce:", "dark:", "light:", "sm:", "md:", "lg:", "xl:", "2xl:",
];
for (const word of words) {
for (const modifier of tailwindModifiers) {
if (word.includes(modifier)) {
return true;
}
}
}
// Check for CSS property combinations
const cssProperties = [
"border", "rounded", "px", "py", "mx", "my", "p", "m", "w", "h", "flex",
"grid", "gap", "transition", "duration", "font", "leading", "tracking",
];
// If the string contains multiple CSS properties, it's likely a CSS class string
let cssPropertyCount = 0;
for (const word of words) {
if (
cssProperties.some(
(prop) => word === prop || word.startsWith(`${prop}-`),
)
) {
cssPropertyCount += 1;
}
}
if (cssPropertyCount >= 2) {
return true;
}
}
// Check for specific CSS class patterns that appear in the test failures
if (
str.match(
/^(border|rounded|flex|grid|transition|duration|ease|hover:|focus:|active:|disabled:|placeholder:|text-|bg-|w-|h-|p-|m-|gap-|items-|justify-|self-|overflow-|cursor-|opacity-|z-|top-|right-|bottom-|left-|inset-|font-|tracking-|leading-|whitespace-|break-|truncate|shadow-|ring-|outline-|animate-|transform|rotate-|scale-|skew-|translate-|origin-|first-of-type:|last-of-type:|group-data-|max-|min-|px-|py-|mx-|my-|grow|shrink|resize-|underline|italic|normal)/,
)
) {
return true;
}
// HTML tags and attributes
if (
/^<[a-z0-9]+(?:\s[^>]*)?>.*<\/[a-z0-9]+>$/i.test(str) ||
/^<[a-z0-9]+ [^>]+\/>$/i.test(str)
) {
return true;
}
// Check for specific patterns in suggestions and examples
if (
str.includes("* ") &&
(str.includes("create a") ||
str.includes("build a") ||
str.includes("make a"))
) {
// This is likely a suggestion or example, not a UI string
return false;
}
// Check for specific technical identifiers from the test failures
if (
/^(download_via_vscode_button_clicked|open-vscode-error-|set-indicator|settings_saved|openhands-trace-|provider-item-|last_browser_action_error)$/.test(
str,
)
) {
return true;
}
// Check for URL paths and query parameters
if (
str.startsWith("?") ||
str.startsWith("/") ||
str.includes("auth.") ||
str.includes("$1auth.")
) {
return true;
}
// Check for specific strings that should be excluded
if (
str === "Cache Hit:" ||
str === "Cache Write:" ||
str === "ADD_DOCS" ||
str === "ADD_DOCKERFILE" ||
str === "Verified" ||
str === "Others" ||
str === "Feedback" ||
str === "JSON File" ||
str === "mt-0.5 md:mt-0"
) {
return true;
}
// Check for long suggestion texts
if (
str.length > 100 &&
(str.includes("Please write a bash script") ||
str.includes("Please investigate the repo") ||
str.includes("Please push the changes") ||
str.includes("Examine the dependencies") ||
str.includes("Investigate the documentation") ||
str.includes("Investigate the current repo") ||
str.includes("I want to create a Hello World app") ||
str.includes("I want to create a VueJS app") ||
str.includes("This should be a client-only app"))
) {
return true;
}
// Check for specific error messages and UI text
if (
str === "All data associated with this project will be lost." ||
str === "You will lose any unsaved information." ||
str ===
"This conversation does not exist, or you do not have permission to access it." ||
str === "Failed to fetch settings. Please try reloading." ||
str ===
"If you tell OpenHands to start a web server, the app will appear here." ||
str ===
"Your browser doesn't support downloading files. Please use Chrome, Edge, or another browser that supports the File System Access API." ||
str ===
"Something went wrong while fetching settings. Please reload the page." ||
str ===
"To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data." ||
str === "Please push the latest changes to the existing pull request."
) {
return true;
}
// Check against all technical patterns
return technicalPatterns.some((pattern) => pattern.test(str));
}
function isLikelyUserFacingText(str) {
// Basic validation - skip very short strings or strings without letters
if (!str || str.length <= 2 || !/[a-zA-Z]/.test(str)) {
return false;
}
// Check if it's a specifically excluded technical string
if (isExcludedTechnicalString(str)) {
return false;
}
// Check if it looks like a code rather than a key
if (isLikelyCode(str)) {
return false
}
// Check if it's a raw translation key that should be wrapped in t()
if (isRawTranslationKey(str)) {
return true;
}
// Check if it's a translation key pattern (e.g., "SETTINGS$BASE_URL")
// These should be wrapped in t() or use I18nKey enum
if (isLikelyTranslationKey(str) && /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str)) {
return true;
}
// First, check if it's a common development string (not user-facing)
if (isCommonDevelopmentString(str)) {
return false;
}
// Multi-word phrases are likely UI text
const hasMultipleWords = /\s+/.test(str) && str.split(/\s+/).length > 1;
// Sentences and questions are likely UI text
const hasPunctuation = /[?!.,:]/.test(str);
const isCapitalizedPhrase = /^[A-Z]/.test(str) && hasMultipleWords;
const isTitleCase = hasMultipleWords && /\s[A-Z]/.test(str);
const hasSentenceStructure = /^[A-Z].*[.!?]$/.test(str); // Starts with capital, ends with punctuation
const hasQuestionForm =
/^(What|How|Why|When|Where|Who|Can|Could|Would|Will|Is|Are|Do|Does|Did|Should|May|Might)/.test(
str,
);
// Product names and camelCase identifiers are likely UI text
const hasInternalCapitals = /[a-z][A-Z]/.test(str); // CamelCase product names
// Instruction text patterns are likely UI text
const looksLikeInstruction =
/^(Enter|Type|Select|Choose|Provide|Specify|Search|Find|Input|Add|Write|Describe|Set|Pick|Browse|Upload|Download|Click|Tap|Press|Go to|Visit|Open|Close)/i.test(
str,
);
// Error and status messages are likely UI text
const looksLikeErrorOrStatus =
/(failed|error|invalid|required|missing|incorrect|wrong|unavailable|not found|not available|try again|success|completed|finished|done|saved|updated|created|deleted|removed|added)/i.test(
str,
);
// Single word check - assume it's UI text unless proven otherwise
const isSingleWord =
!str.includes(" ") && str.length > 1 && /^[a-zA-Z]+$/.test(str);
// For single words, we need to be more careful
if (isSingleWord) {
// Skip common programming terms and variable names
const isCommonProgrammingTerm =
/^(null|undefined|true|false|function|class|interface|type|enum|const|let|var|return|import|export|default|async|await|try|catch|finally|throw|new|this|super|extends|implements|instanceof|typeof|void|delete|in|of|for|while|do|if|else|switch|case|break|continue|yield|static|get|set|public|private|protected|readonly|abstract|implements|namespace|module|declare|as|from|with)$/i.test(
str,
);
if (isCommonProgrammingTerm) {
return false;
}
// Skip common variable name patterns
const looksLikeVariableName =
/^[a-z][a-zA-Z0-9]*$/.test(str) && str.length <= 20;
if (looksLikeVariableName) {
return false;
}
// Skip common CSS values
const isCommonCssValue =
/^(auto|none|hidden|visible|block|inline|flex|grid|row|column|wrap|nowrap|center|start|end|stretch|cover|contain|fixed|absolute|relative|static|sticky|pointer|default|inherit|initial|unset)$/i.test(
str,
);
if (isCommonCssValue) {
return false;
}
// Skip common file extensions
const isFileExtension = /^\.[a-z0-9]+$/i.test(str);
if (isFileExtension) {
return false;
}
// Skip common abbreviations
const isCommonAbbreviation =
/^(id|src|href|url|alt|img|btn|nav|div|span|ul|li|ol|dl|dt|dd|svg|png|jpg|gif|pdf|doc|txt|md|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|toml|csv|mp3|mp4|wav|avi|mov|mpeg|webm|webp|ttf|woff|eot|otf)$/i.test(
str,
);
if (isCommonAbbreviation) {
return false;
}
// If it's a single word that's not a programming term, variable name, CSS value, file extension, or abbreviation,
// it might be UI text, but we'll be conservative and return false
return false;
}
// If it has multiple words, punctuation, or looks like a sentence, it's likely UI text
return (
hasMultipleWords ||
hasPunctuation ||
isCapitalizedPhrase ||
isTitleCase ||
hasSentenceStructure ||
hasQuestionForm ||
hasInternalCapitals ||
looksLikeInstruction ||
looksLikeErrorOrStatus
);
}
function isInTranslationContext(path) {
// Check if the JSX text is inside a <Trans> component
let current = path;
while (current.parentPath) {
if (
current.isJSXElement() &&
current.node.openingElement &&
current.node.openingElement.name &&
current.node.openingElement.name.name === "Trans"
) {
return true;
}
current = current.parentPath;
}
return false;
}
function scanFileForUnlocalizedStrings(filePath) {
// Skip suggestion content files as they contain special strings that are already properly localized
if (filePath.includes("utils/suggestions/") || filePath.includes("mocks/task-suggestions-handlers.ts")) {
return [];
}
try {
const content = fs.readFileSync(filePath, "utf-8");
const unlocalizedStrings = [];
// Skip files that are too large
if (content.length > 1000000) {
console.warn(`Skipping large file: ${filePath}`);
return [];
}
try {
// Parse the file
const ast = parser.parse(content, {
sourceType: "module",
plugins: ["jsx", "typescript", "classProperties", "decorators-legacy"],
});
// Traverse the AST
traverse(ast, {
// Find JSX text content
JSXText(jsxTextPath) {
const text = jsxTextPath.node.value.trim();
if (
text &&
isLikelyUserFacingText(text) &&
!isInTranslationContext(jsxTextPath)
) {
unlocalizedStrings.push(text);
}
},
// Find string literals in JSX attributes
JSXAttribute(jsxAttrPath) {
const attrName = jsxAttrPath.node.name.name.toString();
// Skip technical attributes that don't contain user-facing text
if (NON_TEXT_ATTRIBUTES.includes(attrName)) {
return;
}
// Skip styling attributes
if (
attrName === "className" ||
attrName === "class" ||
attrName === "style"
) {
return;
}
// Skip data attributes and event handlers
if (attrName.startsWith("data-") || attrName.startsWith("on")) {
return;
}
// Check the attribute value
const value = jsxAttrPath.node.value;
if (value && value.type === "StringLiteral") {
const text = value.value.trim();
if (text && isLikelyUserFacingText(text)) {
unlocalizedStrings.push(text);
}
}
},
// Find string literals in code
StringLiteral(stringPath) {
// Skip if parent is JSX attribute (already handled above)
if (stringPath.parent.type === "JSXAttribute") {
return;
}
// Skip if parent is import/export declaration
if (
stringPath.parent.type === "ImportDeclaration" ||
stringPath.parent.type === "ExportDeclaration"
) {
return;
}
// Skip if parent is object property key
if (
stringPath.parent.type === "ObjectProperty" &&
stringPath.parent.key === stringPath.node
) {
return;
}
// Skip if inside a t() call or Trans component
let isInsideTranslation = false;
let current = stringPath;
while (current.parentPath && !isInsideTranslation) {
// Check for t() function call
if (
current.parent.type === "CallExpression" &&
current.parent.callee &&
((current.parent.callee.type === "Identifier" &&
current.parent.callee.name === "t") ||
(current.parent.callee.type === "MemberExpression" &&
current.parent.callee.property &&
current.parent.callee.property.name === "t"))
) {
isInsideTranslation = true;
break;
}
// Check for <Trans> component
if (
current.parent.type === "JSXElement" &&
current.parent.openingElement &&
current.parent.openingElement.name &&
current.parent.openingElement.name.name === "Trans"
) {
isInsideTranslation = true;
break;
}
current = current.parentPath;
}
if (!isInsideTranslation) {
const text = stringPath.node.value.trim();
if (text && isLikelyUserFacingText(text)) {
unlocalizedStrings.push(text);
}
}
},
});
return unlocalizedStrings;
} catch (error) {
console.error(`Error parsing file ${filePath}:`, error);
return [];
}
} catch (error) {
console.error(`Error reading file ${filePath}:`, error);
return [];
}
}
function scanDirectoryForUnlocalizedStrings(dirPath) {
const results = new Map();
function scanDir(currentPath) {
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
if (!shouldIgnorePath(fullPath)) {
if (entry.isDirectory()) {
scanDir(fullPath);
} else if (
entry.isFile() &&
SCAN_EXTENSIONS.includes(path.extname(fullPath))
) {
const unlocalized = scanFileForUnlocalizedStrings(fullPath);
if (unlocalized.length > 0) {
results.set(fullPath, unlocalized);
}
}
}
}
}
scanDir(dirPath);
return results;
}
// Run the check
try {
const srcPath = path.resolve(__dirname, '../src');
console.log('Checking for unlocalized strings in frontend code...');
// Get unlocalized strings using the AST scanner
const results = scanDirectoryForUnlocalizedStrings(srcPath);
// If we found any unlocalized strings, format them for output and exit with error
if (results.size > 0) {
const formattedResults = Array.from(results.entries())
.map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`)
.join('\n');
console.error(`Error: Found unlocalized strings in the following files:${formattedResults}`);
process.exit(1);
}
console.log('✅ No unlocalized strings found in frontend code.');
process.exit(0);
} catch (error) {
console.error('Error running unlocalized strings check:', error);
process.exit(1);
}
@@ -0,0 +1,21 @@
import { openHands } from "../open-hands-axios";
interface GetPromptResponse {
status: string;
prompt: string;
}
export class MemoryService {
static async getPrompt(
conversationId: string,
eventId: number,
): Promise<string> {
const { data } = await openHands.get<GetPromptResponse>(
`/api/conversations/${conversationId}/remember_prompt`,
{
params: { event_id: eventId },
},
);
return data.prompt;
}
}
+32 -4
View File
@@ -258,19 +258,17 @@ class OpenHands {
selectedRepository?: string,
git_provider?: Provider,
initialUserMsg?: string,
imageUrls?: string[],
replayJson?: string,
suggested_task?: SuggestedTask,
selected_branch?: string,
conversationInstructions?: string,
): Promise<Conversation> {
const body = {
repository: selectedRepository,
git_provider,
selected_branch,
initial_user_msg: initialUserMsg,
image_urls: imageUrls,
replay_json: replayJson,
suggested_task,
conversation_instructions: conversationInstructions,
};
const { data } = await openHands.post<Conversation>(
@@ -479,6 +477,36 @@ class OpenHands {
return data.prompt;
}
static async updateConversation(
conversationId: string,
updates: { title: string },
): Promise<boolean> {
const { data } = await openHands.patch<boolean>(
`/api/conversations/${conversationId}`,
updates,
);
return data;
}
/**
* Get the GitHub user installation IDs
* @returns List of GitHub installation IDs
*/
static async getGitHubUserInstallationIds(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/github/installations");
return data;
}
/**
* Get the BitBucket workspaces
* @returns List of BitBucket workspaces
*/
static async getBitBucketWorkspaces(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/bitbucket/installations");
return data;
}
}
export default OpenHands;
+10 -1
View File
@@ -1,5 +1,6 @@
import { ConversationStatus } from "#/types/conversation-status";
import { RuntimeStatus } from "#/types/runtime-status";
import { Provider } from "#/types/settings";
export interface ErrorResponse {
error: string;
@@ -49,9 +50,11 @@ export interface GetConfigResponse {
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
STRIPE_PUBLISHABLE_KEY?: string;
PROVIDERS_CONFIGURED?: Provider[];
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
HIDE_MICROAGENT_MANAGEMENT?: boolean;
};
}
@@ -70,6 +73,12 @@ export interface AuthenticateResponse {
error?: string;
}
export interface RepositorySelection {
selected_repository: string | null;
selected_branch: string | null;
git_provider: Provider | null;
}
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
export interface Conversation {
@@ -77,7 +86,7 @@ export interface Conversation {
title: string;
selected_repository: string | null;
selected_branch: string | null;
git_provider: string | null;
git_provider: Provider | null;
last_updated_at: string;
created_at: string;
status: ConversationStatus;
@@ -19,8 +19,11 @@ export function ActionSuggestions({
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const providersAreSet = providers.length > 0;
const isGitLab = providers.includes("gitlab");
const isBitbucket = providers.includes("bitbucket");
// Use the git_provider from the conversation, not the user's authenticated providers
const currentGitProvider = conversation?.git_provider;
const isGitLab = currentGitProvider === "gitlab";
const isBitbucket = currentGitProvider === "bitbucket";
const pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";
@@ -29,7 +29,7 @@ export function ChatInput({
disabled,
showButton = true,
value,
maxRows = 16,
maxRows = 8,
onSubmit,
onStop,
onChange,
@@ -32,6 +32,7 @@ import { ErrorMessageBanner } from "./error-message-banner";
import { shouldRenderEvent } from "./event-content-helpers/should-render-event";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useConfig } from "#/hooks/query/use-config";
import { validateFiles } from "#/utils/file-validation";
function getEntryPoint(
hasRepository: boolean | null,
@@ -92,9 +93,12 @@ export function ChatInterface() {
const handleSendMessage = async (
content: string,
images: File[],
files: File[],
originalImages: File[],
originalFiles: File[],
) => {
// Create mutable copies of the arrays
const images = [...originalImages];
const files = [...originalFiles];
if (events.length === 0) {
posthog.capture("initial_query_submitted", {
entry_point: getEntryPoint(
@@ -110,6 +114,16 @@ export function ChatInterface() {
current_message_length: content.length,
});
}
// Validate file sizes before any processing
const allFiles = [...images, ...files];
const validation = validateFiles(allFiles);
if (!validation.isValid) {
displayErrorToast(`Error: ${validation.errorMessage}`);
return; // Stop processing if validation fails
}
const promises = images.map((image) => convertImageToBase64(image));
const imageUrls = await Promise.all(promises);
@@ -1,6 +1,7 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { code } from "../markdown/code";
import { cn } from "#/utils/utils";
import { ul, ol } from "../markdown/list";
@@ -12,12 +13,17 @@ import { paragraph } from "../markdown/paragraph";
interface ChatMessageProps {
type: OpenHandsSourceType;
message: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
}>;
}
export function ChatMessage({
type,
message,
children,
actions,
}: React.PropsWithChildren<ChatMessageProps>) {
const [isHovering, setIsHovering] = React.useState(false);
const [isCopy, setIsCopy] = React.useState(false);
@@ -47,18 +53,39 @@ export function ChatMessage({
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={cn(
"rounded-xl relative",
"rounded-xl relative w-fit",
"flex flex-col gap-2",
type === "user" && " max-w-[305px] p-4 bg-tertiary self-end",
type === "agent" && "mt-6 max-w-full bg-transparent",
)}
>
<CopyToClipboardButton
isHidden={!isHovering}
isDisabled={isCopy}
onClick={handleCopyToClipboard}
mode={isCopy ? "copied" : "copy"}
/>
<div
className={cn(
"absolute -top-2.5 -right-2.5",
!isHovering ? "hidden" : "flex",
"items-center gap-1",
)}
>
{actions?.map((action, index) => (
<button
key={index}
type="button"
onClick={action.onClick}
className="button-base p-1 cursor-pointer"
aria-label={`Action ${index + 1}`}
>
{action.icon}
</button>
))}
<CopyToClipboardButton
isHidden={!isHovering}
isDisabled={isCopy}
onClick={handleCopyToClipboard}
mode={isCopy ? "copied" : "copy"}
/>
</div>
<div className="text-sm break-words">
<Markdown
components={{
@@ -68,7 +95,7 @@ export function ChatMessage({
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{message}
</Markdown>
@@ -1,6 +1,7 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { useTranslation } from "react-i18next";
import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
@@ -46,7 +47,7 @@ export function ErrorMessage({ errorId, defaultMessage }: ErrorMessageProps) {
ul,
ol,
}}
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{defaultMessage}
</Markdown>
@@ -19,6 +19,8 @@ import { MCPObservationContent } from "./mcp-observation-content";
import { getObservationResult } from "./event-content-helpers/get-observation-result";
import { getEventContent } from "./event-content-helpers/get-event-content";
import { GenericEventMessage } from "./generic-event-message";
import { MicroagentStatus } from "#/types/microagent-status";
import { MicroagentStatusIndicator } from "./microagent/microagent-status-indicator";
import { FileList } from "../files/file-list";
import { parseMessageFromEvent } from "./event-content-helpers/parse-message-from-event";
import { LikertScale } from "../feedback/likert-scale";
@@ -35,6 +37,13 @@ interface EventMessageProps {
hasObservationPair: boolean;
isAwaitingUserConfirmation: boolean;
isLastMessage: boolean;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
}>;
isInLast10Actions: boolean;
}
@@ -43,6 +52,10 @@ export function EventMessage({
hasObservationPair,
isAwaitingUserConfirmation,
isLastMessage,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
isInLast10Actions,
}: EventMessageProps) {
const shouldShowConfirmationButtons =
@@ -82,27 +95,66 @@ export function EventMessage({
if (isErrorObservation(event)) {
return (
<>
<div>
<ErrorMessage
errorId={event.extras.error_id}
defaultMessage={event.message}
/>
{microagentStatus && actions && (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
)}
{renderLikertScale()}
</>
</div>
);
}
if (hasObservationPair && isOpenHandsAction(event)) {
if (hasThoughtProperty(event.args)) {
return <ChatMessage type="agent" message={event.args.thought} />;
return (
<div>
<ChatMessage
type="agent"
message={event.args.thought}
actions={actions}
/>
{microagentStatus && actions && (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
)}
</div>
);
}
return null;
return microagentStatus && actions ? (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
) : null;
}
if (isFinishAction(event)) {
return (
<>
<ChatMessage type="agent" message={getEventContent(event).details} />
<ChatMessage
type="agent"
message={getEventContent(event).details}
actions={actions}
/>
{microagentStatus && actions && (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
)}
{renderLikertScale()}
</>
);
@@ -113,7 +165,7 @@ export function EventMessage({
return (
<>
<ChatMessage type={event.source} message={message}>
<ChatMessage type={event.source} message={message} actions={actions}>
{event.args.image_urls && event.args.image_urls.length > 0 && (
<ImageCarousel size="small" images={event.args.image_urls} />
)}
@@ -122,6 +174,13 @@ export function EventMessage({
)}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</ChatMessage>
{microagentStatus && actions && (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
)}
{isAssistantMessage(event) &&
event.action === "message" &&
renderLikertScale()}
@@ -130,7 +189,11 @@ export function EventMessage({
}
if (isRejectObservation(event)) {
return <ChatMessage type="agent" message={event.content} />;
return (
<div>
<ChatMessage type="agent" message={event.content} />
</div>
);
}
if (isMcpObservation(event)) {
@@ -4,6 +4,7 @@ import { Trans, useTranslation } from "react-i18next";
import Markdown from "react-markdown";
import { Link } from "react-router";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
@@ -199,7 +200,7 @@ export function ExpandableMessage({
ol,
p: paragraph,
}}
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{details}
</Markdown>
@@ -1,6 +1,7 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
@@ -52,7 +53,7 @@ export function GenericEventMessage({
ul,
ol,
}}
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{details}
</Markdown>
@@ -5,6 +5,8 @@ import { ImageCarousel } from "../images/image-carousel";
import { UploadImageInput } from "../images/upload-image-input";
import { FileList } from "../files/file-list";
import { isFileImage } from "#/utils/is-file-image";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { validateFiles } from "#/utils/file-validation";
interface InteractiveChatBoxProps {
isDisabled?: boolean;
@@ -27,14 +29,20 @@ export function InteractiveChatBox({
const [files, setFiles] = React.useState<File[]>([]);
const handleUpload = (selectedFiles: File[]) => {
setFiles((prevFiles) => [
...prevFiles,
...selectedFiles.filter((f) => !isFileImage(f)),
]);
setImages((prevImages) => [
...prevImages,
...selectedFiles.filter((f) => isFileImage(f)),
]);
// Validate files before adding them
const validation = validateFiles(selectedFiles, [...images, ...files]);
if (!validation.isValid) {
displayErrorToast(`Error: ${validation.errorMessage}`);
return; // Don't add any files if validation fails
}
// Filter valid files by type
const validFiles = selectedFiles.filter((f) => !isFileImage(f));
const validImages = selectedFiles.filter((f) => isFileImage(f));
setFiles((prevFiles) => [...prevFiles, ...validFiles]);
setImages((prevImages) => [...prevImages, ...validImages]);
};
const removeElementByIndex = (array: Array<File>, index: number) => {
@@ -1,10 +1,28 @@
import React from "react";
import { createPortal } from "react-dom";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import { isOpenHandsAction, isOpenHandsObservation } from "#/types/core/guards";
import {
isOpenHandsAction,
isOpenHandsObservation,
isOpenHandsEvent,
isAgentStateChangeObservation,
isFinishAction,
} from "#/types/core/guards";
import { EventMessage } from "./event-message";
import { ChatMessage } from "./chat-message";
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
import { LaunchMicroagentModal } from "./microagent/launch-microagent-modal";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
import {
MicroagentStatus,
EventMicroagentStatus,
} from "#/types/microagent-status";
import { AgentState } from "#/types/agent-state";
import { getFirstPRUrl } from "#/utils/parse-pr-url";
import MemoryIcon from "#/icons/memory_icon.svg?react";
interface MessagesProps {
messages: (OpenHandsAction | OpenHandsObservation)[];
@@ -13,10 +31,23 @@ interface MessagesProps {
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) => {
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
const { getOptimisticUserMessage } = useOptimisticUserMessage();
const { conversationId } = useConversationId();
const { data: conversation } = useUserConversation(conversationId);
const optimisticUserMessage = getOptimisticUserMessage();
const [selectedEventId, setSelectedEventId] = React.useState<number | null>(
null,
);
const [showLaunchMicroagentModal, setShowLaunchMicroagentModal] =
React.useState(false);
const [microagentStatuses, setMicroagentStatuses] = React.useState<
EventMicroagentStatus[]
>([]);
const actionHasObservationPair = React.useCallback(
(event: OpenHandsAction | OpenHandsObservation): boolean => {
if (isOpenHandsAction(event)) {
@@ -30,6 +61,139 @@ export const Messages: React.FC<MessagesProps> = React.memo(
[messages],
);
const getMicroagentStatusForEvent = React.useCallback(
(eventId: number): MicroagentStatus | null => {
const statusEntry = microagentStatuses.find(
(entry) => entry.eventId === eventId,
);
return statusEntry?.status || null;
},
[microagentStatuses],
);
const getMicroagentConversationIdForEvent = React.useCallback(
(eventId: number): string | undefined => {
const statusEntry = microagentStatuses.find(
(entry) => entry.eventId === eventId,
);
return statusEntry?.conversationId || undefined;
},
[microagentStatuses],
);
const getMicroagentPRUrlForEvent = React.useCallback(
(eventId: number): string | undefined => {
const statusEntry = microagentStatuses.find(
(entry) => entry.eventId === eventId,
);
return statusEntry?.prUrl || undefined;
},
[microagentStatuses],
);
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown, microagentConversationId: string) => {
// Handle error events
const isErrorEvent = (
evt: unknown,
): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? { ...statusEntry, status: MicroagentStatus.ERROR }
: statusEntry,
),
);
} else if (
isOpenHandsEvent(socketEvent) &&
isAgentStateChangeObservation(socketEvent)
) {
if (socketEvent.extras.agent_state === AgentState.FINISHED) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? { ...statusEntry, status: MicroagentStatus.COMPLETED }
: statusEntry,
),
);
}
} else if (
isOpenHandsEvent(socketEvent) &&
isFinishAction(socketEvent)
) {
// Check if the finish action contains a PR URL
const prUrl = getFirstPRUrl(socketEvent.args.final_thought || "");
if (prUrl) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? {
...statusEntry,
status: MicroagentStatus.COMPLETED,
prUrl,
}
: statusEntry,
),
);
}
}
},
[setMicroagentStatuses],
);
const handleLaunchMicroagent = (
query: string,
target: string,
triggers: string[],
) => {
const conversationInstructions = `Target file: ${target}\n\nDescription: ${query}\n\nTriggers: ${triggers.join(", ")}`;
if (
!conversation ||
!conversation.selected_repository ||
!conversation.selected_branch ||
!conversation.git_provider ||
!selectedEventId
) {
return;
}
createConversationAndSubscribe({
query,
conversationInstructions,
repository: {
name: conversation.selected_repository,
branch: conversation.selected_branch,
gitProvider: conversation.git_provider,
},
onSuccessCallback: (newConversationId: string) => {
setShowLaunchMicroagentModal(false);
// Update status with conversation ID
setMicroagentStatuses((prev) => [
...prev.filter((status) => status.eventId !== selectedEventId),
{
eventId: selectedEventId,
conversationId: newConversationId,
status: MicroagentStatus.CREATING,
},
]);
},
onEventCallback: (socketEvent: unknown, newConversationId: string) => {
handleMicroagentEvent(socketEvent, newConversationId);
},
});
};
return (
<>
{messages.map((message, index) => (
@@ -39,6 +203,26 @@ export const Messages: React.FC<MessagesProps> = React.memo(
hasObservationPair={actionHasObservationPair(message)}
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
isLastMessage={messages.length - 1 === index}
microagentStatus={getMicroagentStatusForEvent(message.id)}
microagentConversationId={getMicroagentConversationIdForEvent(
message.id,
)}
microagentPRUrl={getMicroagentPRUrlForEvent(message.id)}
actions={
conversation?.selected_repository
? [
{
icon: (
<MemoryIcon className="w-[14px] h-[14px] text-white" />
),
onClick: () => {
setSelectedEventId(message.id);
setShowLaunchMicroagentModal(true);
},
},
]
: undefined
}
isInLast10Actions={messages.length - 1 - index < 10}
/>
))}
@@ -46,6 +230,21 @@ export const Messages: React.FC<MessagesProps> = React.memo(
{optimisticUserMessage && (
<ChatMessage type="user" message={optimisticUserMessage} />
)}
{conversation?.selected_repository &&
showLaunchMicroagentModal &&
selectedEventId &&
createPortal(
<LaunchMicroagentModal
onClose={() => setShowLaunchMicroagentModal(false)}
onLaunch={handleLaunchMicroagent}
selectedRepo={
conversation.selected_repository.split("/").pop() || ""
}
eventId={selectedEventId}
isLoading={isPending}
/>,
document.getElementById("modal-portal-exit") || document.body,
)}
</>
);
},
@@ -0,0 +1,163 @@
import React from "react";
import { FaCircleInfo } from "react-icons/fa6";
import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { BrandButton } from "../../settings/brand-button";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { cn } from "#/utils/utils";
import CloseIcon from "#/icons/close.svg?react";
import { useMicroagentPrompt } from "#/hooks/query/use-microagent-prompt";
import { useHandleRuntimeActive } from "#/hooks/use-handle-runtime-active";
import { LoadingMicroagentBody } from "./loading-microagent-body";
import { LoadingMicroagentTextarea } from "./loading-microagent-textarea";
import { useGetMicroagents } from "#/hooks/query/use-get-microagents";
interface LaunchMicroagentModalProps {
onClose: () => void;
onLaunch: (query: string, target: string, triggers: string[]) => void;
eventId: number;
isLoading: boolean;
selectedRepo: string;
}
export function LaunchMicroagentModal({
onClose,
onLaunch,
eventId,
isLoading,
selectedRepo,
}: LaunchMicroagentModalProps) {
const { t } = useTranslation();
const { runtimeActive } = useHandleRuntimeActive();
const { data: prompt, isLoading: promptIsLoading } =
useMicroagentPrompt(eventId);
const { data: microagents, isLoading: microagentsIsLoading } =
useGetMicroagents(`${selectedRepo}/.openhands/microagents`);
const [triggers, setTriggers] = React.useState<string[]>([]);
const formAction = (formData: FormData) => {
const query = formData.get("query-input")?.toString();
const target = formData.get("target-input")?.toString();
if (query && target) {
onLaunch(query, target, triggers);
}
};
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
formAction(formData);
};
return (
<ModalBackdrop onClose={onClose}>
{!runtimeActive && <LoadingMicroagentBody />}
{runtimeActive && (
<ModalBody className="items-start w-[728px]">
<div className="flex items-center justify-between w-full">
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
{t("MICROAGENT$ADD_TO_MICROAGENT")}
<a
href="https://docs.all-hands.dev/usage/prompting/microagents-overview#microagents-overview"
target="_blank"
rel="noopener noreferrer"
>
<FaCircleInfo className="text-primary" />
</a>
</h2>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
<form
data-testid="launch-microagent-modal"
onSubmit={onSubmit}
className="flex flex-col gap-6 w-full"
>
<label
htmlFor="query-input"
className="flex flex-col gap-2.5 w-full text-sm"
>
{t("MICROAGENT$WHAT_TO_REMEMBER")}
{promptIsLoading && <LoadingMicroagentTextarea />}
{!promptIsLoading && (
<textarea
required
data-testid="query-input"
name="query-input"
defaultValue={prompt}
placeholder={t("MICROAGENT$DESCRIBE_WHAT_TO_ADD")}
rows={6}
className={cn(
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
)}
</label>
<SettingsDropdownInput
testId="target-input"
name="target-input"
label={t("MICROAGENT$WHERE_TO_PUT")}
placeholder={t("MICROAGENT$SELECT_FILE_OR_CUSTOM")}
required
allowsCustomValue
isLoading={microagentsIsLoading}
items={
microagents?.map((item) => ({
key: item,
label: item,
})) || []
}
/>
<label
htmlFor="trigger-input"
className="flex flex-col gap-2.5 w-full text-sm"
>
<div className="flex items-center gap-2">
{t("MICROAGENT$ADD_TRIGGERS")}
<a
href="https://docs.all-hands.dev/usage/prompting/microagents-keyword"
target="_blank"
rel="noopener noreferrer"
>
<FaCircleInfo className="text-primary" />
</a>
</div>
<BadgeInput
name="trigger-input"
value={triggers}
placeholder={t("MICROAGENT$TYPE_TRIGGER_SPACE")}
onChange={setTriggers}
/>
</label>
<div className="flex items-center justify-end gap-2">
<BrandButton type="button" variant="secondary" onClick={onClose}>
{t("MICROAGENT$CANCEL")}
</BrandButton>
<BrandButton
type="submit"
variant="primary"
isDisabled={
isLoading || promptIsLoading || microagentsIsLoading
}
>
{t("MICROAGENT$LAUNCH")}
</BrandButton>
</div>
</form>
</ModalBody>
)}
</ModalBackdrop>
);
}
@@ -0,0 +1,16 @@
import { Spinner } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { ModalBody } from "#/components/shared/modals/modal-body";
export function LoadingMicroagentBody() {
const { t } = useTranslation();
return (
<ModalBody>
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
{t("MICROAGENT$ADD_TO_MICROAGENT")}
</h2>
<Spinner size="lg" />
<p>{t("MICROAGENT$WAIT_FOR_RUNTIME")}</p>
</ModalBody>
);
}

Some files were not shown because too many files have changed in this diff Show More