From 0e9906f41edf10fb7b19955db8cd692fafc33a08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 16:15:31 +0400 Subject: [PATCH 01/10] chore(deps): bump posthog-js from 1.260.3 to 1.261.0 in /frontend in the version-all group (#10658) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 16 ++++++++-------- frontend/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a07c42db58..6e9b1f01ef 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,7 +37,7 @@ "jose": "^6.0.13", "lucide-react": "^0.542.0", "monaco-editor": "^0.52.2", - "posthog-js": "^1.260.3", + "posthog-js": "^1.261.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-highlight": "^0.15.0", @@ -3876,9 +3876,9 @@ "license": "MIT" }, "node_modules/@posthog/core": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.0.1.tgz", - "integrity": "sha512-bwXUeHe+MLgENm8+/FxEbiNocOw1Vjewmm+HEUaYQe6frq8OhZnrvtnzZU3Q3DF6N0UbAmD/q+iNfNgyx8mozg==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.0.2.tgz", + "integrity": "sha512-hWk3rUtJl2crQK0WNmwg13n82hnTwB99BT99/XI5gZSvIlYZ1TPmMZE8H2dhJJ98J/rm9vYJ/UXNzw3RV5HTpQ==" }, "node_modules/@react-aria/breadcrumbs": { "version": "3.5.27", @@ -14906,11 +14906,11 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.260.3", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.260.3.tgz", - "integrity": "sha512-FCtksk0GQn22Rk9P7x7dsmAO7a2aBxPeYb2O2KXSraxR8xd2G6lUOOthVDK+qgtmuhpUZuur/mHrXEslMUEtjg==", + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.261.0.tgz", + "integrity": "sha512-jyiXqyrCU+VlpbNNVRA6OQYAVut0XZMYNELCZH+XvTd981VqbE4jXn4XCBreo7XCL2gdPgDVxUVOuzNvEuKcmw==", "dependencies": { - "@posthog/core": "1.0.1", + "@posthog/core": "1.0.2", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", diff --git a/frontend/package.json b/frontend/package.json index a8fc9d097a..c20c999ea2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,7 +36,7 @@ "jose": "^6.0.13", "lucide-react": "^0.542.0", "monaco-editor": "^0.52.2", - "posthog-js": "^1.260.3", + "posthog-js": "^1.261.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-highlight": "^0.15.0", From 9709431874ae59ca466ec9ee4b597a862356a511 Mon Sep 17 00:00:00 2001 From: Ray Myers Date: Thu, 28 Aug 2025 08:20:39 -0500 Subject: [PATCH 02/10] fix: cli dedupe TaskTrackingAction thoughts by using display_thought_if_new (#10660) Co-authored-by: openhands --- openhands/cli/tui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands/cli/tui.py b/openhands/cli/tui.py index e356ecf181..659020d0fc 100644 --- a/openhands/cli/tui.py +++ b/openhands/cli/tui.py @@ -522,7 +522,7 @@ def display_task_tracking_action(event: TaskTrackingAction) -> None: """Display a TaskTracking action in the CLI.""" # Display thought first if present if hasattr(event, 'thought') and event.thought: - display_message(event.thought) + display_thought_if_new(event.thought) # Format the command and task list for display display_text = f'Command: {event.command}' From 81829289ab7305eb8e15fcf2f08c5bcd91c5ce7c Mon Sep 17 00:00:00 2001 From: "Ryan H. Tran" Date: Thu, 28 Aug 2025 20:22:28 +0700 Subject: [PATCH 03/10] Add support for passing list of `Message` into LLM `completion` (#10671) --- .../agenthub/browsing_agent/browsing_agent.py | 2 +- .../agenthub/codeact_agent/codeact_agent.py | 2 +- .../visualbrowsing_agent.py | 4 +--- openhands/llm/llm.py | 18 +++++++++++++++--- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/openhands/agenthub/browsing_agent/browsing_agent.py b/openhands/agenthub/browsing_agent/browsing_agent.py index bf2c0960a0..cd1decd261 100644 --- a/openhands/agenthub/browsing_agent/browsing_agent.py +++ b/openhands/agenthub/browsing_agent/browsing_agent.py @@ -217,7 +217,7 @@ class BrowsingAgent(Agent): messages.append(Message(role='user', content=[TextContent(text=prompt)])) response = self.llm.completion( - messages=self.llm.format_messages_for_llm(messages), + messages=messages, stop=[')```', ')\n```'], ) return self.response_parser.parse(response) diff --git a/openhands/agenthub/codeact_agent/codeact_agent.py b/openhands/agenthub/codeact_agent/codeact_agent.py index 402d4ba1d1..1f4686c0f0 100644 --- a/openhands/agenthub/codeact_agent/codeact_agent.py +++ b/openhands/agenthub/codeact_agent/codeact_agent.py @@ -204,7 +204,7 @@ class CodeActAgent(Agent): initial_user_message = self._get_initial_user_message(state.history) messages = self._get_messages(condensed_history, initial_user_message) params: dict = { - 'messages': self.llm.format_messages_for_llm(messages), + 'messages': messages, } params['tools'] = check_tools(self.tools, self.llm.config) params['extra_body'] = { diff --git a/openhands/agenthub/visualbrowsing_agent/visualbrowsing_agent.py b/openhands/agenthub/visualbrowsing_agent/visualbrowsing_agent.py index 322629d3a3..0603da38f9 100644 --- a/openhands/agenthub/visualbrowsing_agent/visualbrowsing_agent.py +++ b/openhands/agenthub/visualbrowsing_agent/visualbrowsing_agent.py @@ -301,10 +301,8 @@ You are an agent trying to solve a web task based on the content of the page and messages.append(Message(role='system', content=[TextContent(text=system_msg)])) messages.append(Message(role='user', content=human_prompt)) - flat_messages = self.llm.format_messages_for_llm(messages) - response = self.llm.completion( - messages=flat_messages, + messages=messages, temperature=0.0, stop=[')```', ')\n```'], ) diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py index d3926848cc..fab3de1e40 100644 --- a/openhands/llm/llm.py +++ b/openhands/llm/llm.py @@ -3,7 +3,7 @@ import os import time import warnings from functools import partial -from typing import Any, Callable +from typing import Any, Callable, cast import httpx @@ -220,7 +220,9 @@ class LLM(RetryMixin, DebugMixin): """Wrapper for the litellm completion function. Logs the input and output of the completion function.""" from openhands.io import json - messages_kwarg: list[dict[str, Any]] | dict[str, Any] = [] + messages_kwarg: ( + dict[str, Any] | Message | list[dict[str, Any]] | list[Message] + ) = [] mock_function_calling = not self.is_function_calling_active() # some callers might send the model and messages directly @@ -239,9 +241,19 @@ class LLM(RetryMixin, DebugMixin): messages_kwarg = kwargs['messages'] # ensure we work with a list of messages - messages: list[dict[str, Any]] = ( + messages_list = ( messages_kwarg if isinstance(messages_kwarg, list) else [messages_kwarg] ) + # Format Message objects to dict format if needed + messages: list[dict] = [] + if messages_list and isinstance(messages_list[0], Message): + messages = self.format_messages_for_llm( + cast(list[Message], messages_list) + ) + else: + messages = cast(list[dict[str, Any]], messages_list) + + kwargs['messages'] = messages # handle conversion of to non-function calling messages if needed original_fncall_messages = copy.deepcopy(messages) From 23713bfe8c8e270e9503810df33d9f6d7c627d73 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 14:53:14 +0000 Subject: [PATCH 04/10] chore(deps): bump the version-all group in /frontend with 5 updates (#10686) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 49 +++++++++++++++++++------------------- frontend/package.json | 10 ++++---- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6e9b1f01ef..0f83ba41ca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,7 +22,7 @@ "@tailwindcss/vite": "^4.1.12", "@tanstack/react-query": "^5.85.5", "@uidotdev/usehooks": "^2.4.1", - "@vitejs/plugin-react": "^5.0.1", + "@vitejs/plugin-react": "^5.0.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.4.0", "axios": "^1.11.0", @@ -34,7 +34,7 @@ "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.30", - "jose": "^6.0.13", + "jose": "^6.1.0", "lucide-react": "^0.542.0", "monaco-editor": "^0.52.2", "posthog-js": "^1.261.0", @@ -73,8 +73,8 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.3.0", - "@types/react": "^19.1.11", - "@types/react-dom": "^19.1.8", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@types/react-highlight": "^0.12.8", "@types/react-syntax-highlighter": "^15.5.13", "@types/ws": "^8.18.1", @@ -99,7 +99,7 @@ "lint-staged": "^16.1.4", "msw": "^2.6.6", "prettier": "^3.6.2", - "stripe": "^18.4.0", + "stripe": "^18.5.0", "tailwindcss": "^4.1.8", "typescript": "^5.9.2", "vite-plugin-svgr": "^4.5.0", @@ -5423,9 +5423,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", - "integrity": "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==" + "version": "1.0.0-beta.34", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", + "integrity": "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==" }, "node_modules/@rollup/pluginutils": { "version": "5.2.0", @@ -6690,17 +6690,17 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.11", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz", - "integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==", + "version": "19.1.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", + "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", "dependencies": { "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.8.tgz", - "integrity": "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ==", + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", + "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "dev": true, "peerDependencies": { "@types/react": "^19.0.0" @@ -7189,14 +7189,14 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.1.tgz", - "integrity": "sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.2.tgz", + "integrity": "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==", "dependencies": { "@babel/core": "^7.28.3", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.32", + "@rolldown/pluginutils": "1.0.0-beta.34", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -12040,9 +12040,9 @@ } }, "node_modules/jose": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.13.tgz", - "integrity": "sha512-Yms4GpbmdANamS51kKK6w4hRlKx8KTxbWyAAKT/MhUMtqbIqh5mb2HjhTNUbk7TFL8/MBB5zWSDohL7ed4k/UA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -16976,11 +16976,10 @@ "license": "MIT" }, "node_modules/stripe": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.4.0.tgz", - "integrity": "sha512-LKFeDnDYo4U/YzNgx2Lc9PT9XgKN0JNF1iQwZxgkS4lOw5NunWCnzyH5RhTlD3clIZnf54h7nyMWkS8VXPmtTQ==", + "version": "18.5.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.5.0.tgz", + "integrity": "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==", "dev": true, - "license": "MIT", "dependencies": { "qs": "^6.11.0" }, diff --git a/frontend/package.json b/frontend/package.json index c20c999ea2..25d98523f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,7 @@ "@tailwindcss/vite": "^4.1.12", "@tanstack/react-query": "^5.85.5", "@uidotdev/usehooks": "^2.4.1", - "@vitejs/plugin-react": "^5.0.1", + "@vitejs/plugin-react": "^5.0.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.4.0", "axios": "^1.11.0", @@ -33,7 +33,7 @@ "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.30", - "jose": "^6.0.13", + "jose": "^6.1.0", "lucide-react": "^0.542.0", "monaco-editor": "^0.52.2", "posthog-js": "^1.261.0", @@ -97,8 +97,8 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.3.0", - "@types/react": "^19.1.11", - "@types/react-dom": "^19.1.8", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@types/react-highlight": "^0.12.8", "@types/react-syntax-highlighter": "^15.5.13", "@types/ws": "^8.18.1", @@ -123,7 +123,7 @@ "lint-staged": "^16.1.4", "msw": "^2.6.6", "prettier": "^3.6.2", - "stripe": "^18.4.0", + "stripe": "^18.5.0", "tailwindcss": "^4.1.8", "typescript": "^5.9.2", "vite-plugin-svgr": "^4.5.0", From 7e3eabe777f31b958401a211db3a959dbbfb387f Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Thu, 28 Aug 2025 14:15:20 -0400 Subject: [PATCH 05/10] (Hotfix): ConversationStats metrics loss for unregistered services (#10676) Co-authored-by: openhands --- .../server/services/conversation_stats.py | 25 +++- tests/unit/test_conversation_stats.py | 122 ++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/openhands/server/services/conversation_stats.py b/openhands/server/services/conversation_stats.py index 89ed789f6c..ff72681db9 100644 --- a/openhands/server/services/conversation_stats.py +++ b/openhands/server/services/conversation_stats.py @@ -36,7 +36,30 @@ class ConversationStats: return with self._save_lock: - pickled = pickle.dumps(self.service_to_metrics) + # Check for duplicate service IDs between restored and service metrics + duplicate_services = set(self.restored_metrics.keys()) & set( + self.service_to_metrics.keys() + ) + if duplicate_services: + logger.error( + f'Duplicate service IDs found between restored and service metrics: {duplicate_services}. ' + 'This should not happen as registered services should be removed from restored_metrics. ' + 'Proceeding by preferring service_to_metrics values for duplicates.', + extra={ + 'conversation_id': self.conversation_id, + 'duplicate_services': list(duplicate_services), + }, + ) + + # Combine both restored metrics and service metrics to avoid data loss + # Start with restored metrics (for services not yet registered) + combined_metrics = self.restored_metrics.copy() + + # Add service metrics (for registered services) + # Since we checked for duplicates above, this is safe + combined_metrics.update(self.service_to_metrics) + + pickled = pickle.dumps(combined_metrics) serialized_metrics = base64.b64encode(pickled).decode('utf-8') self.file_store.write(self.metrics_path, serialized_metrics) logger.info( diff --git a/tests/unit/test_conversation_stats.py b/tests/unit/test_conversation_stats.py index 61de343f89..59b656684d 100644 --- a/tests/unit/test_conversation_stats.py +++ b/tests/unit/test_conversation_stats.py @@ -488,3 +488,125 @@ def test_save_and_restore_workflow(mock_file_store): assert llm.metrics.accumulated_cost == 0.05 assert llm.metrics.accumulated_token_usage.prompt_tokens == 100 assert llm.metrics.accumulated_token_usage.completion_tokens == 50 + + +def test_save_metrics_preserves_restored_metrics_fix(mock_file_store): + """Test that save_metrics correctly preserves restored metrics for unregistered services.""" + conversation_id = 'test-conversation-id' + user_id = 'test-user-id' + + # Step 1: Create initial conversation stats with multiple services + stats1 = ConversationStats( + file_store=mock_file_store, conversation_id=conversation_id, user_id=user_id + ) + + # Add metrics for multiple services + service_a = 'service-a' + service_b = 'service-b' + service_c = 'service-c' + + metrics_a = Metrics(model_name='gpt-4') + metrics_a.add_cost(0.10) + + metrics_b = Metrics(model_name='gpt-3.5') + metrics_b.add_cost(0.05) + + metrics_c = Metrics(model_name='claude-3') + metrics_c.add_cost(0.08) + + stats1.service_to_metrics[service_a] = metrics_a + stats1.service_to_metrics[service_b] = metrics_b + stats1.service_to_metrics[service_c] = metrics_c + + # Save metrics (all three services should be saved) + stats1.save_metrics() + + # Step 2: Create new conversation stats instance (simulates app restart) + stats2 = ConversationStats( + file_store=mock_file_store, conversation_id=conversation_id, user_id=user_id + ) + + # Verify all metrics were restored + assert service_a in stats2.restored_metrics + assert service_b in stats2.restored_metrics + assert service_c in stats2.restored_metrics + assert stats2.restored_metrics[service_a].accumulated_cost == 0.10 + assert stats2.restored_metrics[service_b].accumulated_cost == 0.05 + assert stats2.restored_metrics[service_c].accumulated_cost == 0.08 + + # Step 3: Register only one LLM service (simulates partial LLM activation) + llm_config = LLMConfig( + model='gpt-4o', + api_key='test_key', + num_retries=2, + retry_min_wait=1, + retry_max_wait=2, + ) + + with patch('openhands.llm.llm.litellm_completion'): + llm_a = LLM(service_id=service_a, config=llm_config) + event_a = RegistryEvent(llm=llm_a, service_id=service_a) + stats2.register_llm(event_a) + + # Verify service_a was moved from restored_metrics to service_to_metrics + assert service_a in stats2.service_to_metrics + assert service_a not in stats2.restored_metrics + assert stats2.service_to_metrics[service_a].accumulated_cost == 0.10 + + # Verify services B and C are still in restored_metrics (not yet registered) + assert service_b in stats2.restored_metrics + assert service_c in stats2.restored_metrics + assert stats2.restored_metrics[service_b].accumulated_cost == 0.05 + assert stats2.restored_metrics[service_c].accumulated_cost == 0.08 + + # Step 4: Save metrics again (this is where the bug occurs) + stats2.save_metrics() + + # Step 5: Create a third conversation stats instance to verify what was saved + stats3 = ConversationStats( + file_store=mock_file_store, conversation_id=conversation_id, user_id=user_id + ) + + # FIXED: All services should be restored because save_metrics now combines both dictionaries + # Service A should be restored with its current metrics from service_to_metrics + assert service_a in stats3.restored_metrics + assert stats3.restored_metrics[service_a].accumulated_cost == 0.10 + + # Services B and C should be preserved from restored_metrics + assert service_b in stats3.restored_metrics # FIXED: Now preserved + assert service_c in stats3.restored_metrics # FIXED: Now preserved + assert stats3.restored_metrics[service_b].accumulated_cost == 0.05 + assert stats3.restored_metrics[service_c].accumulated_cost == 0.08 + + +def test_save_metrics_throws_error_on_duplicate_service_ids(mock_file_store): + """Test updated: save_metrics should NOT raise on duplicate service IDs; it should prefer service_to_metrics and proceed.""" + conversation_id = 'test-conversation-id' + user_id = 'test-user-id' + + stats = ConversationStats( + file_store=mock_file_store, conversation_id=conversation_id, user_id=user_id + ) + + # Manually create a scenario with duplicate service IDs (this should never happen in normal operation) + service_id = 'duplicate-service' + + # Add to both restored_metrics and service_to_metrics + restored_metrics = Metrics(model_name='gpt-4') + restored_metrics.add_cost(0.10) + stats.restored_metrics[service_id] = restored_metrics + + service_metrics = Metrics(model_name='gpt-3.5') + service_metrics.add_cost(0.05) + stats.service_to_metrics[service_id] = service_metrics + + # Should not raise. Should save with service_to_metrics preferred. + stats.save_metrics() + + # Verify the saved content prefers service_to_metrics for duplicates + encoded = mock_file_store.read(stats.metrics_path) + pickled = base64.b64decode(encoded) + restored = pickle.loads(pickled) + + assert service_id in restored + assert restored[service_id].accumulated_cost == 0.05 # prefers service_to_metrics From 5b35203253bb77a87d7efd89a4cfdaeca1b0bafe Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Fri, 29 Aug 2025 03:24:48 +0700 Subject: [PATCH 06/10] refactor: remove branch dropdown, update title, fix pr_number issue (microagent management) (#10691) --- .../microagent-management-content.tsx | 1 - ...microagent-management-repo-microagents.tsx | 2 +- ...ent-management-upsert-microagent-modal.tsx | 114 +----------------- ...ate-conversation-and-subscribe-multiple.ts | 2 +- frontend/src/i18n/declaration.ts | 2 +- frontend/src/i18n/translation.json | 32 ++--- frontend/src/types/microagent-management.tsx | 1 - openhands/server/routes/mcp.py | 4 +- 8 files changed, 25 insertions(+), 133 deletions(-) diff --git a/frontend/src/components/features/microagent-management/microagent-management-content.tsx b/frontend/src/components/features/microagent-management/microagent-management-content.tsx index 7be9a9df05..315e7b9e67 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-content.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-content.tsx @@ -240,7 +240,6 @@ export function MicroagentManagementContent() { conversationInstructions, repository: { name: repositoryName, - branch: formData.selectedBranch, gitProvider, }, createMicroagent, diff --git a/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx b/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx index 939eb288fe..7466314512 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx @@ -137,7 +137,7 @@ export function MicroagentManagementRepoMicroagents({ {hasConversations && (
- {t(I18nKey.MICROAGENT_MANAGEMENT$OPEN_MICROAGENT_PULL_REQUESTS)} + {t(I18nKey.COMMON$IN_PROGRESS)} {conversations?.map((conversation) => (
diff --git a/frontend/src/components/features/microagent-management/microagent-management-upsert-microagent-modal.tsx b/frontend/src/components/features/microagent-management/microagent-management-upsert-microagent-modal.tsx index 0f9f9494cc..0351b387db 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-upsert-microagent-modal.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-upsert-microagent-modal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useMemo } from "react"; +import { useEffect, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; import { FaCircleInfo } from "react-icons/fa6"; @@ -11,14 +11,8 @@ import XIcon from "#/icons/x.svg?react"; import { cn, extractRepositoryInfo } from "#/utils/utils"; import { BadgeInput } from "#/components/shared/inputs/badge-input"; import { MicroagentFormData } from "#/types/microagent-management"; -import { Branch, GitRepository } from "#/types/git"; -import { useRepositoryBranches } from "#/hooks/query/use-repository-branches"; +import { GitRepository } from "#/types/git"; import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content"; -import { - BranchDropdown, - BranchLoadingState, - BranchErrorState, -} from "../home/repository-selection"; interface MicroagentManagementUpsertMicroagentModalProps { onConfirm: (formData: MicroagentFormData) => void; @@ -37,7 +31,6 @@ export function MicroagentManagementUpsertMicroagentModal({ const [triggers, setTriggers] = useState([]); const [query, setQuery] = useState(""); - const [selectedBranch, setSelectedBranch] = useState(null); const { selectedRepository } = useSelector( (state: RootState) => state.microagentManagement, @@ -49,9 +42,6 @@ export function MicroagentManagementUpsertMicroagentModal({ const { microagent } = selectedMicroagentItem ?? {}; - // Add a ref to track if the branch was manually cleared by the user - const branchManuallyClearedRef = useRef(false); - // Extract owner and repo from full_name for content API const { owner, repo, filePath } = extractRepositoryInfo( selectedRepository, @@ -70,38 +60,6 @@ export function MicroagentManagementUpsertMicroagentModal({ } }, [isUpdate, microagentContentData]); - const { - data: branches, - isLoading: isLoadingBranches, - isError: isBranchesError, - } = useRepositoryBranches(selectedRepository?.full_name || null); - - const branchesItems = branches?.map((branch) => ({ - key: branch.name, - label: branch.name, - })); - - // Auto-select main or master branch if it exists. - useEffect(() => { - if ( - branches && - branches.length > 0 && - !selectedBranch && - !isLoadingBranches - ) { - // Look for main or master branch - const mainBranch = branches.find((branch) => branch.name === "main"); - const masterBranch = branches.find((branch) => branch.name === "master"); - - // Select main if it exists, otherwise select master if it exists - if (mainBranch) { - setSelectedBranch(mainBranch); - } else if (masterBranch) { - setSelectedBranch(masterBranch); - } - } - }, [branches, isLoadingBranches, selectedBranch]); - const modalTitle = useMemo(() => { if (isUpdate) { return t(I18nKey.MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT); @@ -134,7 +92,6 @@ export function MicroagentManagementUpsertMicroagentModal({ onConfirm({ query: query.trim(), triggers, - selectedBranch: selectedBranch?.name || "", microagentPath: microagent?.path || "", }); }; @@ -147,67 +104,10 @@ export function MicroagentManagementUpsertMicroagentModal({ onConfirm({ query: query.trim(), triggers, - selectedBranch: selectedBranch?.name || "", microagentPath: microagent?.path || "", }); }; - const handleBranchSelection = (key: React.Key | null) => { - const selectedBranchObj = branches?.find((branch) => branch.name === key); - setSelectedBranch(selectedBranchObj || null); - // Reset the manually cleared flag when a branch is explicitly selected - branchManuallyClearedRef.current = false; - }; - - const handleBranchInputChange = (value: string) => { - // Clear the selected branch if the input is empty or contains only whitespace - // This fixes the issue where users can't delete the entire default branch name - if (value === "" || value.trim() === "") { - setSelectedBranch(null); - // Set the flag to indicate that the branch was manually cleared - branchManuallyClearedRef.current = true; - } else { - // Reset the flag when the user starts typing again - branchManuallyClearedRef.current = false; - } - }; - - // Render the appropriate UI for branch selector based on the loading/error state - const renderBranchSelector = () => { - if (!selectedRepository) { - return ( - {}} - onInputChange={() => {}} - isDisabled - wrapperClassName="max-w-full w-full" - label={t(I18nKey.REPOSITORY$SELECT_BRANCH)} - /> - ); - } - - if (isLoadingBranches) { - return ; - } - - if (isBranchesError) { - return ; - } - - return ( - - ); - }; - return ( @@ -236,7 +136,6 @@ export function MicroagentManagementUpsertMicroagentModal({ onSubmit={onSubmit} className="flex flex-col gap-6 w-full" > - {renderBranchSelector()}
+ ); +} diff --git a/frontend/src/components/features/home/git-branch-dropdown/index.ts b/frontend/src/components/features/home/git-branch-dropdown/index.ts new file mode 100644 index 0000000000..e72b5504c7 --- /dev/null +++ b/frontend/src/components/features/home/git-branch-dropdown/index.ts @@ -0,0 +1,3 @@ +export { GitBranchDropdown } from "./git-branch-dropdown"; +export { BranchDropdownMenu } from "./branch-dropdown-menu"; +export type { GitBranchDropdownProps } from "./git-branch-dropdown"; diff --git a/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx b/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx new file mode 100644 index 0000000000..1123f7e022 --- /dev/null +++ b/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx @@ -0,0 +1,193 @@ +import React, { useState, useMemo, useEffect } from "react"; +import { useCombobox } from "downshift"; +import { Provider } from "#/types/settings"; +import { cn } from "#/utils/utils"; +import { DropdownItem } from "../shared/dropdown-item"; +import { GenericDropdownMenu } from "../shared/generic-dropdown-menu"; +import { ToggleButton } from "../shared/toggle-button"; +import { LoadingSpinner } from "../shared/loading-spinner"; +import { ErrorMessage } from "../shared/error-message"; +import { EmptyState } from "../shared/empty-state"; + +export interface GitProviderDropdownProps { + providers: Provider[]; + value?: Provider | null; + placeholder?: string; + className?: string; + errorMessage?: string; + disabled?: boolean; + isLoading?: boolean; + onChange?: (provider: Provider | null) => void; +} + +export function GitProviderDropdown({ + providers, + value, + placeholder = "Select Provider", + className, + errorMessage, + disabled = false, + isLoading = false, + onChange, +}: GitProviderDropdownProps) { + const [inputValue, setInputValue] = useState(""); + const [localSelectedItem, setLocalSelectedItem] = useState( + value || null, + ); + + // Format provider names for display + const formatProviderName = (provider: Provider): string => { + switch (provider) { + case "github": + return "GitHub"; + case "gitlab": + return "GitLab"; + case "bitbucket": + return "Bitbucket"; + case "enterprise_sso": + return "Enterprise SSO"; + default: + // Fallback for any future provider types + return ( + (provider as string).charAt(0).toUpperCase() + + (provider as string).slice(1) + ); + } + }; + + // Filter providers based on input value + const filteredProviders = useMemo(() => { + // If we have a selected provider and the input matches it exactly, show all providers + if ( + localSelectedItem && + inputValue === formatProviderName(localSelectedItem) + ) { + return providers; + } + + // If no input value, show all providers + if (!inputValue || !inputValue.trim()) { + return providers; + } + + // Filter providers based on input + return providers.filter((provider) => + formatProviderName(provider) + .toLowerCase() + .includes(inputValue.toLowerCase()), + ); + }, [providers, inputValue, localSelectedItem]); + + const { + isOpen, + getToggleButtonProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + selectedItem, + } = useCombobox({ + items: filteredProviders, + itemToString: (item) => (item ? formatProviderName(item) : ""), + selectedItem: localSelectedItem, + onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { + setLocalSelectedItem(newSelectedItem || null); + onChange?.(newSelectedItem || null); + }, + onInputValueChange: ({ inputValue: newInputValue }) => { + setInputValue(newInputValue || ""); + }, + inputValue, + }); + + // Sync with external value prop + useEffect(() => { + if (value !== localSelectedItem) { + setLocalSelectedItem(value || null); + } + }, [value, localSelectedItem]); + + // Update input value when selection changes (but not when user is typing) + useEffect(() => { + if (selectedItem && !isOpen) { + setInputValue(formatProviderName(selectedItem)); + } else if (!selectedItem) { + setInputValue(""); + } + }, [selectedItem, isOpen]); + + const renderItem = ( + item: Provider, + index: number, + currentHighlightedIndex: number, + currentSelectedItem: Provider | null, + currentGetItemProps: any, // eslint-disable-line @typescript-eslint/no-explicit-any + ) => ( + provider} + /> + ); + + const renderEmptyState = (currentInputValue: string) => ( + + ); + + return ( +
+
+ + +
+ +
+ + {isLoading && } +
+ + + + +
+ ); +} diff --git a/frontend/src/components/features/home/git-provider-dropdown/index.ts b/frontend/src/components/features/home/git-provider-dropdown/index.ts new file mode 100644 index 0000000000..422b8fa73f --- /dev/null +++ b/frontend/src/components/features/home/git-provider-dropdown/index.ts @@ -0,0 +1,2 @@ +export { GitProviderDropdown } from "./git-provider-dropdown"; +export type { GitProviderDropdownProps } from "./git-provider-dropdown"; diff --git a/frontend/src/components/features/home/git-repo-dropdown/dropdown-menu.tsx b/frontend/src/components/features/home/git-repo-dropdown/dropdown-menu.tsx new file mode 100644 index 0000000000..bee4b5e45e --- /dev/null +++ b/frontend/src/components/features/home/git-repo-dropdown/dropdown-menu.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { + UseComboboxGetMenuPropsOptions, + UseComboboxGetItemPropsOptions, +} from "downshift"; +import { GitRepository } from "#/types/git"; +import { DropdownItem } from "../shared/dropdown-item"; +import { GenericDropdownMenu, EmptyState } from "../shared"; + +interface DropdownMenuProps { + isOpen: boolean; + filteredRepositories: GitRepository[]; + inputValue: string; + highlightedIndex: number; + selectedItem: GitRepository | null; + getMenuProps: ( + options?: UseComboboxGetMenuPropsOptions & Options, + ) => any; // eslint-disable-line @typescript-eslint/no-explicit-any + getItemProps: ( + options: UseComboboxGetItemPropsOptions & Options, + ) => any; // eslint-disable-line @typescript-eslint/no-explicit-any + onScroll: (event: React.UIEvent) => void; + menuRef: React.RefObject; +} + +export function DropdownMenu({ + isOpen, + filteredRepositories, + inputValue, + highlightedIndex, + selectedItem, + getMenuProps, + getItemProps, + onScroll, + menuRef, +}: DropdownMenuProps) { + const renderItem = ( + repository: GitRepository, + index: number, + currentHighlightedIndex: number, + currentSelectedItem: GitRepository | null, + currentGetItemProps: ( + options: UseComboboxGetItemPropsOptions & Options, + ) => any, // eslint-disable-line @typescript-eslint/no-explicit-any + ) => ( + repo.full_name} + getItemKey={(repo) => repo.id.toString()} + /> + ); + + const renderEmptyState = (currentInputValue: string) => ( + + ); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx b/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx new file mode 100644 index 0000000000..cc75148818 --- /dev/null +++ b/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx @@ -0,0 +1,243 @@ +import React, { + useState, + useMemo, + useCallback, + useRef, + useEffect, +} from "react"; +import { useCombobox } from "downshift"; +import { Provider } from "#/types/settings"; +import { GitRepository } from "#/types/git"; +import { useDebounce } from "#/hooks/use-debounce"; +import { cn } from "#/utils/utils"; +import { LoadingSpinner } from "../shared/loading-spinner"; +import { ClearButton } from "../shared/clear-button"; +import { ToggleButton } from "../shared/toggle-button"; +import { ErrorMessage } from "../shared/error-message"; +import { useUrlSearch } from "./use-url-search"; +import { useRepositoryData } from "./use-repository-data"; +import { DropdownMenu } from "./dropdown-menu"; + +export interface GitRepoDropdownProps { + provider: Provider; + value?: string | null; + placeholder?: string; + className?: string; + disabled?: boolean; + onChange?: (repository?: GitRepository) => void; +} + +export function GitRepoDropdown({ + provider, + value, + placeholder = "Search repositories...", + className, + disabled = false, + onChange, +}: GitRepoDropdownProps) { + const [inputValue, setInputValue] = useState(""); + const [localSelectedItem, setLocalSelectedItem] = + useState(null); + const debouncedInputValue = useDebounce(inputValue, 300); + const menuRef = useRef(null); + + // Process search input to handle URLs + const processedSearchInput = useMemo(() => { + if (debouncedInputValue.startsWith("https://")) { + const match = debouncedInputValue.match( + /https:\/\/[^/]+\/([^/]+\/[^/]+)/, + ); + return match ? match[1] : debouncedInputValue; + } + return debouncedInputValue; + }, [debouncedInputValue]); + + // URL search functionality + const { urlSearchResults, isUrlSearchLoading } = useUrlSearch( + inputValue, + provider, + ); + + // Repository data management + const { + repositories, + selectedRepository, + fetchNextPage, + hasNextPage, + isLoading, + isFetchingNextPage, + isError, + isSearchLoading, + } = useRepositoryData( + provider, + disabled, + processedSearchInput, + urlSearchResults, + inputValue, + value, + ); + + // Filter repositories based on input value + const filteredRepositories = useMemo(() => { + // If we have URL search results, show them directly (no filtering needed) + if (urlSearchResults.length > 0) { + return repositories; + } + + // If we have a selected repository and the input matches it exactly, show all repositories + if (selectedRepository && inputValue === selectedRepository.full_name) { + return repositories; + } + + // If no input value, show all repositories + if (!inputValue || !inputValue.trim()) { + return repositories; + } + + // For URL inputs, use the processed search input for filtering + const filterText = inputValue.startsWith("https://") + ? processedSearchInput + : inputValue; + + return repositories.filter((repo) => + repo.full_name.toLowerCase().includes(filterText.toLowerCase()), + ); + }, [ + repositories, + inputValue, + selectedRepository, + urlSearchResults, + processedSearchInput, + ]); + + // Handle selection + const handleSelectionChange = useCallback( + (selectedItem: GitRepository | null) => { + setLocalSelectedItem(selectedItem); + onChange?.(selectedItem || undefined); + // Update input value to show selected item + if (selectedItem) { + setInputValue(selectedItem.full_name); + } + }, + [onChange], + ); + + // Handle clear selection + const handleClear = useCallback(() => { + setLocalSelectedItem(null); + handleSelectionChange(null); + setInputValue(""); + }, [handleSelectionChange]); + + // Handle input value change + const handleInputValueChange = useCallback( + ({ inputValue: newInputValue }: { inputValue?: string }) => { + setInputValue(newInputValue || ""); + }, + [], + ); + + // Handle scroll to bottom for pagination + const handleMenuScroll = useCallback( + (event: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; + const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10; + + if (isNearBottom && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + [hasNextPage, isFetchingNextPage, fetchNextPage], + ); + + const { + isOpen, + getToggleButtonProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + selectedItem, + } = useCombobox({ + items: filteredRepositories, + itemToString: (item) => item?.full_name || "", + selectedItem: localSelectedItem, + onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { + handleSelectionChange(newSelectedItem); + }, + onInputValueChange: handleInputValueChange, + inputValue, + }); + + // Sync localSelectedItem with external value prop + useEffect(() => { + if (selectedRepository) { + setLocalSelectedItem(selectedRepository); + } else if (value === null) { + setLocalSelectedItem(null); + } + }, [selectedRepository, value]); + + // Initialize input value when selectedRepository changes (but not when user is typing) + useEffect(() => { + if (selectedRepository && !isOpen) { + setInputValue(selectedRepository.full_name); + } + }, [selectedRepository, isOpen]); + + const isLoadingState = + isLoading || isSearchLoading || isFetchingNextPage || isUrlSearchLoading; + + return ( +
+
+ + +
+ {selectedRepository && ( + + )} + + +
+ + {isLoadingState && ( + + )} +
+ + + + +
+ ); +} diff --git a/frontend/src/components/features/home/git-repo-dropdown/index.tsx b/frontend/src/components/features/home/git-repo-dropdown/index.tsx new file mode 100644 index 0000000000..5322bbede8 --- /dev/null +++ b/frontend/src/components/features/home/git-repo-dropdown/index.tsx @@ -0,0 +1,10 @@ +// Main component +export { GitRepoDropdown } from "./git-repo-dropdown"; +export type { GitRepoDropdownProps } from "./git-repo-dropdown"; + +// Repository-specific UI Components +export { DropdownMenu } from "./dropdown-menu"; + +// Repository-specific Custom Hooks +export { useUrlSearch } from "./use-url-search"; +export { useRepositoryData } from "./use-repository-data"; diff --git a/frontend/src/components/features/home/git-repo-dropdown/use-repository-data.tsx b/frontend/src/components/features/home/git-repo-dropdown/use-repository-data.tsx new file mode 100644 index 0000000000..0ee7344f45 --- /dev/null +++ b/frontend/src/components/features/home/git-repo-dropdown/use-repository-data.tsx @@ -0,0 +1,89 @@ +import { useMemo } from "react"; +import { Provider } from "#/types/settings"; +import { GitRepository } from "#/types/git"; +import { useGitRepositories } from "#/hooks/query/use-git-repositories"; +import { useSearchRepositories } from "#/hooks/query/use-search-repositories"; + +export function useRepositoryData( + provider: Provider, + disabled: boolean, + processedSearchInput: string, + urlSearchResults: GitRepository[], + inputValue: string, + value?: string | null, +) { + // Fetch user repositories with pagination + const { + data: repoData, + fetchNextPage, + hasNextPage, + isLoading, + isFetchingNextPage, + isError, + } = useGitRepositories({ + provider, + enabled: !disabled, + }); + + // Search repositories when user types + const { data: searchData, isLoading: isSearchLoading } = + useSearchRepositories(processedSearchInput, provider); + + // Combine all repositories from paginated data + const allRepositories = useMemo( + () => repoData?.pages?.flatMap((page) => page.data) || [], + [repoData], + ); + + // Find selected repository from all possible sources + const selectedRepository = useMemo(() => { + if (!value) return null; + + // Search in all possible repository sources + const allPossibleRepos = [ + ...allRepositories, + ...urlSearchResults, + ...(searchData || []), + ]; + + return allPossibleRepos.find((repo) => repo.id === value) || null; + }, [allRepositories, urlSearchResults, searchData, value]); + + // Get repositories to display (URL search, regular search, or all repos) + const repositories = useMemo(() => { + // Prioritize URL search results when available + if (urlSearchResults.length > 0) { + return urlSearchResults; + } + + // Don't use search results if input exactly matches selected repository + const shouldUseSearch = + processedSearchInput && + searchData && + !(selectedRepository && inputValue === selectedRepository.full_name); + + if (shouldUseSearch) { + return searchData; + } + return allRepositories; + }, [ + urlSearchResults, + processedSearchInput, + searchData, + allRepositories, + selectedRepository, + inputValue, + ]); + + return { + repositories, + allRepositories, + selectedRepository, + fetchNextPage, + hasNextPage, + isLoading, + isFetchingNextPage, + isError, + isSearchLoading, + }; +} diff --git a/frontend/src/components/features/home/git-repo-dropdown/use-url-search.tsx b/frontend/src/components/features/home/git-repo-dropdown/use-url-search.tsx new file mode 100644 index 0000000000..82cb6d27f2 --- /dev/null +++ b/frontend/src/components/features/home/git-repo-dropdown/use-url-search.tsx @@ -0,0 +1,41 @@ +import { useState, useEffect } from "react"; +import { Provider } from "#/types/settings"; +import { GitRepository } from "#/types/git"; +import OpenHands from "#/api/open-hands"; + +export function useUrlSearch(inputValue: string, provider: Provider) { + const [urlSearchResults, setUrlSearchResults] = useState([]); + const [isUrlSearchLoading, setIsUrlSearchLoading] = useState(false); + + useEffect(() => { + const handleUrlSearch = async () => { + if (inputValue.startsWith("https://")) { + const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/); + if (match) { + const repoName = match[1]; + + setIsUrlSearchLoading(true); + try { + const repositories = await OpenHands.searchGitRepositories( + repoName, + 3, + provider, + ); + + setUrlSearchResults(repositories); + } catch (error) { + setUrlSearchResults([]); + } finally { + setIsUrlSearchLoading(false); + } + } + } else { + setUrlSearchResults([]); + } + }; + + handleUrlSearch(); + }, [inputValue, provider]); + + return { urlSearchResults, isUrlSearchLoading }; +} diff --git a/frontend/src/components/features/home/repo-selection-form.tsx b/frontend/src/components/features/home/repo-selection-form.tsx index c7835f8e86..ce8a007f73 100644 --- a/frontend/src/components/features/home/repo-selection-form.tsx +++ b/frontend/src/components/features/home/repo-selection-form.tsx @@ -2,15 +2,15 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; -import { useRepositoryBranches } from "#/hooks/query/use-repository-branches"; +// Removed useRepositoryBranches import - GitBranchDropdown manages its own data import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; import { Branch, GitRepository } from "#/types/git"; import { BrandButton } from "../settings/brand-button"; import { useUserProviders } from "#/hooks/use-user-providers"; import { Provider } from "#/types/settings"; -import { GitProviderDropdown } from "../../common/git-provider-dropdown"; -import { GitRepositoryDropdown } from "../../common/git-repository-dropdown"; -import { GitBranchDropdown } from "../../common/git-branch-dropdown"; +import { GitProviderDropdown } from "./git-provider-dropdown"; +import { GitBranchDropdown } from "./git-branch-dropdown"; +import { GitRepoDropdown } from "./git-repo-dropdown"; interface RepositorySelectionFormProps { onRepoSelection: (repo: GitRepository | null) => void; @@ -28,8 +28,6 @@ export function RepositorySelectionForm({ const [selectedProvider, setSelectedProvider] = React.useState(null); const { providers } = useUserProviders(); - const { data: branches, isLoading: isLoadingBranches } = - useRepositoryBranches(selectedRepository?.full_name || null); const { mutate: createConversation, isPending, @@ -50,8 +48,7 @@ export function RepositorySelectionForm({ const isCreatingConversation = isPending || isSuccess || isCreatingConversationElsewhere; - // Check if repository has no branches (empty array after loading completes) - const hasNoBranches = !isLoadingBranches && branches && branches.length === 0; + // Branch selection is now handled by GitBranchDropdown component const handleProviderSelection = (provider: Provider | null) => { setSelectedProvider(provider); @@ -60,14 +57,9 @@ export function RepositorySelectionForm({ onRepoSelection(null); // Reset parent component's selected repo }; - const handleBranchSelection = (branchName: string | null) => { - const selectedBranchObj = branches?.find( - (branch) => branch.name === branchName, - ); - if (selectedBranchObj) { - setSelectedBranch(selectedBranchObj); - } - }; + const handleBranchSelection = React.useCallback((branch: Branch | null) => { + setSelectedBranch(branch); + }, []); // Render the provider dropdown const renderProviderSelector = () => { @@ -87,19 +79,6 @@ export function RepositorySelectionForm({ ); }; - // Effect to auto-select main/master branch when branches are loaded - React.useEffect(() => { - if (branches?.length) { - // Look for main or master branch - const defaultBranch = branches.find( - (branch) => branch.name === "main" || branch.name === "master", - ); - - // If found, select it, otherwise select the first branch - setSelectedBranch(defaultBranch || branches[0]); - } - }, [branches]); - // Render the repository selector using our new component const renderRepositorySelector = () => { const handleRepoSelection = (repository?: GitRepository) => { @@ -107,13 +86,14 @@ export function RepositorySelectionForm({ onRepoSelection(repository); setSelectedRepository(repository); } else { + onRepoSelection(null); // Notify parent component that repo was cleared setSelectedRepository(null); setSelectedBranch(null); } }; return ( - ( - - ); + const renderBranchSelector = () => { + const defaultBranch = selectedRepository?.main_branch || null; + return ( + + ); + }; return (
@@ -148,8 +133,7 @@ export function RepositorySelectionForm({ type="button" isDisabled={ !selectedRepository || - (!selectedBranch && !hasNoBranches) || - isLoadingBranches || + !selectedBranch || isCreatingConversation || (providers.length > 1 && !selectedProvider) } @@ -159,7 +143,7 @@ export function RepositorySelectionForm({ repository: { name: selectedRepository?.full_name || "", gitProvider: selectedRepository?.git_provider || "github", - branch: selectedBranch?.name || (hasNoBranches ? "" : "main"), + branch: selectedBranch?.name || "main", }, }, { diff --git a/frontend/src/components/features/home/shared/clear-button.tsx b/frontend/src/components/features/home/shared/clear-button.tsx new file mode 100644 index 0000000000..db2206b866 --- /dev/null +++ b/frontend/src/components/features/home/shared/clear-button.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { cn } from "#/utils/utils"; + +interface ClearButtonProps { + disabled: boolean; + onClear: () => void; + testId?: string; +} + +export function ClearButton({ + disabled, + onClear, + testId = "dropdown-clear", +}: ClearButtonProps) { + return ( + + ); +} diff --git a/frontend/src/components/features/home/shared/dropdown-item.tsx b/frontend/src/components/features/home/shared/dropdown-item.tsx new file mode 100644 index 0000000000..df3830963a --- /dev/null +++ b/frontend/src/components/features/home/shared/dropdown-item.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { cn } from "#/utils/utils"; + +interface DropdownItemProps { + item: T; + index: number; + isHighlighted: boolean; + isSelected: boolean; + getItemProps: (options: any & Options) => any; // eslint-disable-line @typescript-eslint/no-explicit-any + getDisplayText: (item: T) => string; + getItemKey: (item: T) => string; +} + +export function DropdownItem({ + item, + index, + isHighlighted, + isSelected, + getItemProps, + getDisplayText, + getItemKey, +}: DropdownItemProps) { + const itemProps = getItemProps({ + index, + item, + className: cn( + "px-3 py-2 cursor-pointer text-sm rounded-lg mx-0.5 my-0.5", + "text-[#ECEDEE] focus:outline-none", + { + "bg-[#24272E]": isHighlighted && !isSelected, + "bg-[#C9B974] text-black": isSelected, + "hover:bg-[#24272E]": !isSelected, + "hover:bg-[#C9B974] hover:text-black": isSelected, + }, + ), + }); + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading +
  • + {getDisplayText(item)} +
  • + ); +} diff --git a/frontend/src/components/features/home/shared/empty-state.tsx b/frontend/src/components/features/home/shared/empty-state.tsx new file mode 100644 index 0000000000..9e65d23fe3 --- /dev/null +++ b/frontend/src/components/features/home/shared/empty-state.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +interface EmptyStateProps { + inputValue: string; + searchMessage?: string; + emptyMessage?: string; + testId?: string; +} + +export function EmptyState({ + inputValue, + searchMessage = "No items found", + emptyMessage = "No items available", + testId = "dropdown-empty", +}: EmptyStateProps) { + return ( +
  • + {inputValue ? searchMessage : emptyMessage} +
  • + ); +} diff --git a/frontend/src/components/features/home/shared/error-message.tsx b/frontend/src/components/features/home/shared/error-message.tsx new file mode 100644 index 0000000000..df4cc05e99 --- /dev/null +++ b/frontend/src/components/features/home/shared/error-message.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +interface ErrorMessageProps { + isError: boolean; + message?: string; + testId?: string; +} + +export function ErrorMessage({ + isError, + message = "Failed to load data", + testId = "dropdown-error", +}: ErrorMessageProps) { + if (!isError) return null; + + return ( +
    + {message} +
    + ); +} diff --git a/frontend/src/components/features/home/shared/generic-dropdown-menu.tsx b/frontend/src/components/features/home/shared/generic-dropdown-menu.tsx new file mode 100644 index 0000000000..65e11f8eff --- /dev/null +++ b/frontend/src/components/features/home/shared/generic-dropdown-menu.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { + UseComboboxGetMenuPropsOptions, + UseComboboxGetItemPropsOptions, +} from "downshift"; +import { cn } from "#/utils/utils"; + +export interface GenericDropdownMenuProps { + isOpen: boolean; + filteredItems: T[]; + inputValue: string; + highlightedIndex: number; + selectedItem: T | null; + getMenuProps: ( + options?: UseComboboxGetMenuPropsOptions & Options, + ) => any; // eslint-disable-line @typescript-eslint/no-explicit-any + getItemProps: ( + options: UseComboboxGetItemPropsOptions & Options, + ) => any; // eslint-disable-line @typescript-eslint/no-explicit-any + onScroll?: (event: React.UIEvent) => void; + menuRef?: React.RefObject; + renderItem: ( + item: T, + index: number, + highlightedIndex: number, + selectedItem: T | null, + getItemProps: ( + options: UseComboboxGetItemPropsOptions & Options, + ) => any, // eslint-disable-line @typescript-eslint/no-explicit-any + ) => React.ReactNode; + renderEmptyState: (inputValue: string) => React.ReactNode; +} + +export function GenericDropdownMenu({ + isOpen, + filteredItems, + inputValue, + highlightedIndex, + selectedItem, + getMenuProps, + getItemProps, + onScroll, + menuRef, + renderItem, + renderEmptyState, +}: GenericDropdownMenuProps) { + if (!isOpen) return null; + + return ( +
      + {filteredItems.length === 0 + ? renderEmptyState(inputValue) + : filteredItems.map((item, index) => + renderItem( + item, + index, + highlightedIndex, + selectedItem, + getItemProps, + ), + )} +
    + ); +} diff --git a/frontend/src/components/features/home/shared/index.ts b/frontend/src/components/features/home/shared/index.ts new file mode 100644 index 0000000000..4c92cb75b5 --- /dev/null +++ b/frontend/src/components/features/home/shared/index.ts @@ -0,0 +1,7 @@ +export { GenericDropdownMenu } from "./generic-dropdown-menu"; +export { EmptyState } from "./empty-state"; +export { ErrorMessage } from "./error-message"; +export { LoadingSpinner } from "./loading-spinner"; +export { ClearButton } from "./clear-button"; +export { ToggleButton } from "./toggle-button"; +export type { GenericDropdownMenuProps } from "./generic-dropdown-menu"; diff --git a/frontend/src/components/features/home/shared/loading-spinner.tsx b/frontend/src/components/features/home/shared/loading-spinner.tsx new file mode 100644 index 0000000000..5d9af18e93 --- /dev/null +++ b/frontend/src/components/features/home/shared/loading-spinner.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { cn } from "#/utils/utils"; + +interface LoadingSpinnerProps { + hasSelection: boolean; + testId?: string; +} + +export function LoadingSpinner({ + hasSelection, + testId = "dropdown-loading", +}: LoadingSpinnerProps) { + return ( +
    +
    +
    + ); +} diff --git a/frontend/src/components/features/home/shared/toggle-button.tsx b/frontend/src/components/features/home/shared/toggle-button.tsx new file mode 100644 index 0000000000..2905043ca4 --- /dev/null +++ b/frontend/src/components/features/home/shared/toggle-button.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { cn } from "#/utils/utils"; + +interface ToggleButtonProps { + isOpen: boolean; + disabled: boolean; + getToggleButtonProps: ( + props?: Record, + ) => Record; +} + +export function ToggleButton({ + isOpen, + disabled, + getToggleButtonProps, +}: ToggleButtonProps) { + return ( + + ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx b/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx index f05343fd51..a7decafc83 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx @@ -5,7 +5,7 @@ import { Spinner } from "@heroui/react"; import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header"; import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs"; import { useGitRepositories } from "#/hooks/query/use-git-repositories"; -import { GitProviderDropdown } from "#/components/common/git-provider-dropdown"; +import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown"; import { setPersonalRepositories, setOrganizationRepositories, @@ -16,7 +16,6 @@ import { Provider } from "#/types/settings"; import { cn } from "#/utils/utils"; import { sanitizeQuery } from "#/utils/sanitize-query"; import { I18nKey } from "#/i18n/declaration"; -import { getGitProviderMicroagentManagementCustomStyles } from "#/components/common/react-select-styles"; interface MicroagentManagementSidebarProps { isSmallerScreen?: boolean; @@ -123,8 +122,6 @@ export function MicroagentManagementSidebar({ placeholder="Select Provider" onChange={handleProviderChange} className="w-full" - classNamePrefix="git-provider-dropdown" - styles={getGitProviderMicroagentManagementCustomStyles()} />
    )} diff --git a/frontend/src/hooks/query/use-branch-data.ts b/frontend/src/hooks/query/use-branch-data.ts new file mode 100644 index 0000000000..2173cf4c6e --- /dev/null +++ b/frontend/src/hooks/query/use-branch-data.ts @@ -0,0 +1,126 @@ +import { useMemo } from "react"; +import { useRepositoryBranchesPaginated } from "./use-repository-branches"; +import { useSearchBranches } from "./use-search-branches"; +import { Branch } from "#/types/git"; +import { Provider } from "#/types/settings"; + +export function useBranchData( + repository: string | null, + provider: Provider, + defaultBranch: string | null, + processedSearchInput: string, + inputValue: string, + selectedBranch?: Branch | null, +) { + // Fetch branches with pagination + const { + data: branchData, + fetchNextPage, + hasNextPage, + isLoading, + isFetchingNextPage, + isError, + } = useRepositoryBranchesPaginated(repository); + + // Search branches when user types + const { data: searchData, isLoading: isSearchLoading } = useSearchBranches( + repository, + processedSearchInput, + 30, + provider, + ); + + // Combine all branches from paginated data + const allBranches = useMemo( + () => branchData?.pages?.flatMap((page) => page.branches) || [], + [branchData], + ); + + // Check if default branch is in the loaded branches + const defaultBranchInLoaded = useMemo( + () => + defaultBranch + ? allBranches.find((branch) => branch.name === defaultBranch) + : null, + [allBranches, defaultBranch], + ); + + // Only search for default branch if it's not already in the loaded branches + // and we have loaded some branches (to avoid searching immediately on mount) + const shouldSearchDefaultBranch = + defaultBranch && + !defaultBranchInLoaded && + allBranches.length > 0 && + !processedSearchInput; // Don't search for default branch when user is searching + + const { data: defaultBranchData, isLoading: isDefaultBranchLoading } = + useSearchBranches( + repository, + shouldSearchDefaultBranch ? defaultBranch : "", + 30, + provider, + ); + + // Get branches to display with default branch prioritized + const branches = useMemo(() => { + // Don't use search results if input exactly matches selected branch + const shouldUseSearch = + processedSearchInput && + searchData && + !(selectedBranch && inputValue === selectedBranch.name); + + let branchesToUse = shouldUseSearch ? searchData : allBranches; + + // If we have a default branch, ensure it's at the top of the list + if (defaultBranch) { + // Use the already computed defaultBranchInLoaded or check in current branches + let defaultBranchObj = shouldUseSearch + ? branchesToUse.find((branch) => branch.name === defaultBranch) + : defaultBranchInLoaded; + + // If not found in current branches, check if we have it from the default branch search + if ( + !defaultBranchObj && + defaultBranchData && + defaultBranchData.length > 0 + ) { + defaultBranchObj = defaultBranchData.find( + (branch) => branch.name === defaultBranch, + ); + + // Add the default branch to the beginning of the list + if (defaultBranchObj) { + branchesToUse = [defaultBranchObj, ...branchesToUse]; + } + } else if (defaultBranchObj) { + // If found in current branches, move it to the front + const otherBranches = branchesToUse.filter( + (branch) => branch.name !== defaultBranch, + ); + branchesToUse = [defaultBranchObj, ...otherBranches]; + } + } + + return branchesToUse; + }, [ + processedSearchInput, + searchData, + allBranches, + selectedBranch, + inputValue, + defaultBranch, + defaultBranchInLoaded, + defaultBranchData, + ]); + + return { + branches, + allBranches, + fetchNextPage, + hasNextPage, + isLoading: isLoading || isDefaultBranchLoading, + isFetchingNextPage, + isError, + isSearchLoading, + }; +} diff --git a/frontend/src/hooks/query/use-repository-branches.ts b/frontend/src/hooks/query/use-repository-branches.ts index 64d80d6f62..de78556fc4 100644 --- a/frontend/src/hooks/query/use-repository-branches.ts +++ b/frontend/src/hooks/query/use-repository-branches.ts @@ -1,14 +1,46 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; import OpenHands from "#/api/open-hands"; -import { Branch } from "#/types/git"; +import { Branch, PaginatedBranchesResponse } from "#/types/git"; export const useRepositoryBranches = (repository: string | null) => useQuery({ queryKey: ["repository", repository, "branches"], queryFn: async () => { if (!repository) return []; - return OpenHands.getRepositoryBranches(repository); + const response = await OpenHands.getRepositoryBranches(repository); + // Ensure we return an array even if the response is malformed + return Array.isArray(response.branches) ? response.branches : []; }, enabled: !!repository, staleTime: 1000 * 60 * 5, // 5 minutes }); + +export const useRepositoryBranchesPaginated = ( + repository: string | null, + perPage: number = 30, +) => + useInfiniteQuery({ + queryKey: ["repository", repository, "branches", "paginated", perPage], + queryFn: async ({ pageParam = 1 }) => { + if (!repository) { + return { + branches: [], + has_next_page: false, + current_page: 1, + per_page: perPage, + total_count: 0, + }; + } + return OpenHands.getRepositoryBranches( + repository, + pageParam as number, + perPage, + ); + }, + enabled: !!repository, + staleTime: 1000 * 60 * 5, // 5 minutes + getNextPageParam: (lastPage) => + // Use the has_next_page flag from the API response + lastPage.has_next_page ? lastPage.current_page + 1 : undefined, + initialPageParam: 1, + }); diff --git a/frontend/src/hooks/query/use-search-branches.ts b/frontend/src/hooks/query/use-search-branches.ts new file mode 100644 index 0000000000..b2ce4af078 --- /dev/null +++ b/frontend/src/hooks/query/use-search-branches.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; +import OpenHands from "#/api/open-hands"; +import { Branch } from "#/types/git"; +import { Provider } from "#/types/settings"; + +export function useSearchBranches( + repository: string | null, + query: string, + perPage: number = 30, + selectedProvider?: Provider, +) { + return useQuery({ + queryKey: [ + "repository", + repository, + "branches", + "search", + query, + perPage, + selectedProvider, + ], + queryFn: async () => { + if (!repository || !query) return []; + return OpenHands.searchRepositoryBranches( + repository, + query, + perPage, + selectedProvider, + ); + }, + enabled: !!repository && !!query, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 15, + }); +} diff --git a/frontend/src/mocks/git-repository-handlers.ts b/frontend/src/mocks/git-repository-handlers.ts index 9f8d74bff4..ef836a8346 100644 --- a/frontend/src/mocks/git-repository-handlers.ts +++ b/frontend/src/mocks/git-repository-handlers.ts @@ -1,6 +1,8 @@ import { delay, http, HttpResponse } from "msw"; -import { GitRepository } from "#/types/git"; +import { GitRepository, Branch, PaginatedBranchesResponse } from "#/types/git"; import { Provider } from "#/types/settings"; +import { RepositoryMicroagent } from "#/types/microagent-management"; +import { MicroagentContentResponse } from "#/api/open-hands.types"; // Generate a list of mock repositories with realistic data const generateMockRepositories = ( @@ -19,6 +21,32 @@ const generateMockRepositories = ( owner_type: Math.random() > 0.7 ? "organization" : "user", // 30% chance of being organization })); +// Generate mock branches for a repository +const generateMockBranches = (count: number): Branch[] => + Array.from({ length: count }, (_, i) => ({ + name: (() => { + if (i === 0) return "main"; + if (i === 1) return "develop"; + return `feature/branch-${i}`; + })(), + commit_sha: `abc123${i.toString().padStart(3, "0")}`, + protected: i === 0, // main branch is protected + last_push_date: new Date( + Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000, + ).toISOString(), + })); + +// Generate mock microagents for a repository +const generateMockMicroagents = (count: number): RepositoryMicroagent[] => + Array.from({ length: count }, (_, i) => ({ + name: `microagent-${i + 1}`, + path: `.openhands/microagents/microagent-${i + 1}.md`, + created_at: new Date( + Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000, + ).toISOString(), + git_provider: "github", + })); + // Mock repositories for each provider const MOCK_REPOSITORIES = { github: generateMockRepositories(120, "github"), @@ -26,6 +54,12 @@ const MOCK_REPOSITORIES = { bitbucket: generateMockRepositories(120, "bitbucket"), }; +// Mock branches (same for all repos for simplicity) +const MOCK_BRANCHES = generateMockBranches(25); + +// Mock microagents (same for all repos for simplicity) +const MOCK_MICROAGENTS = generateMockMicroagents(5); + export const GIT_REPOSITORY_HANDLERS = [ http.get("/api/user/repositories", async ({ request }) => { await delay(500); // Simulate network delay @@ -154,4 +188,138 @@ export const GIT_REPOSITORY_HANDLERS = [ return HttpResponse.json(limitedRepos); }), + + // Repository branches endpoint + http.get("/api/user/repository/branches", async ({ request }) => { + await delay(300); + + const url = new URL(request.url); + const repository = url.searchParams.get("repository"); + const page = parseInt(url.searchParams.get("page") || "1", 10); + const perPage = parseInt(url.searchParams.get("per_page") || "30", 10); + + if (!repository) { + return HttpResponse.json("Repository parameter is required", { + status: 400, + }); + } + + // Calculate pagination + const startIndex = (page - 1) * perPage; + const endIndex = startIndex + perPage; + const paginatedBranches = MOCK_BRANCHES.slice(startIndex, endIndex); + const hasNextPage = endIndex < MOCK_BRANCHES.length; + + const response: PaginatedBranchesResponse = { + branches: paginatedBranches, + has_next_page: hasNextPage, + current_page: page, + per_page: perPage, + total_count: MOCK_BRANCHES.length, + }; + + return HttpResponse.json(response); + }), + + // Search repository branches endpoint + http.get("/api/user/search/branches", async ({ request }) => { + await delay(200); + + const url = new URL(request.url); + const repository = url.searchParams.get("repository"); + const query = url.searchParams.get("query") || ""; + const perPage = parseInt(url.searchParams.get("per_page") || "30", 10); + + if (!repository) { + return HttpResponse.json("Repository parameter is required", { + status: 400, + }); + } + + // Filter branches by search query + const filteredBranches = MOCK_BRANCHES.filter((branch) => + branch.name.toLowerCase().includes(query.toLowerCase()), + ); + + // Limit results + const limitedBranches = filteredBranches.slice(0, perPage); + + return HttpResponse.json(limitedBranches); + }), + + // Repository microagents endpoint + http.get( + "/api/user/repository/:owner/:repo/microagents", + async ({ params }) => { + await delay(400); + + const { owner, repo } = params; + + if (!owner || !repo) { + return HttpResponse.json("Owner and repo parameters are required", { + status: 400, + }); + } + + return HttpResponse.json(MOCK_MICROAGENTS); + }, + ), + + // Repository microagent content endpoint + http.get( + "/api/user/repository/:owner/:repo/microagents/content", + async ({ request, params }) => { + await delay(300); + + const { owner, repo } = params; + const url = new URL(request.url); + const filePath = url.searchParams.get("file_path"); + + if (!owner || !repo || !filePath) { + return HttpResponse.json( + "Owner, repo, and file_path parameters are required", + { status: 400 }, + ); + } + + // Find the microagent by path + const microagent = MOCK_MICROAGENTS.find((m) => m.path === filePath); + + if (!microagent) { + return HttpResponse.json("Microagent not found", { status: 404 }); + } + + const response: MicroagentContentResponse = { + content: `# ${microagent.name} + +A helpful microagent for repository tasks. + +## Instructions + +This microagent helps with specific tasks related to the repository. + +### Usage + +1. Describe your task clearly +2. The microagent will analyze the context +3. Follow the provided recommendations + +### Capabilities + +- Code analysis +- Task automation +- Best practice recommendations +- Error detection and resolution + +--- + +*Generated mock content for ${microagent.name}*`, + path: microagent.path, + git_provider: "github", + triggers: ["code review", "bug fix", "feature development"], + }; + + return HttpResponse.json(response); + }, + ), ]; diff --git a/frontend/src/types/git.d.ts b/frontend/src/types/git.d.ts index a5f1daa483..9a5eb62ee5 100644 --- a/frontend/src/types/git.d.ts +++ b/frontend/src/types/git.d.ts @@ -22,6 +22,14 @@ interface Branch { last_push_date?: string; } +interface PaginatedBranchesResponse { + branches: Branch[]; + has_next_page: boolean; + current_page: number; + per_page: number; + total_count?: number; +} + interface GitRepository { id: string; full_name: string; @@ -31,6 +39,7 @@ interface GitRepository { link_header?: string; pushed_at?: string; owner_type?: "user" | "organization"; + main_branch?: string; } interface GitHubCommit { diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index f42e34a555..c40437f430 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -5,6 +5,7 @@ import "@testing-library/jest-dom/vitest"; HTMLCanvasElement.prototype.getContext = vi.fn(); HTMLElement.prototype.scrollTo = vi.fn(); +window.scrollTo = vi.fn(); // Mock the i18n provider vi.mock("react-i18next", async (importOriginal) => ({ diff --git a/openhands/integrations/bitbucket/bitbucket_service.py b/openhands/integrations/bitbucket/bitbucket_service.py index d536892db1..d2f5908566 100644 --- a/openhands/integrations/bitbucket/bitbucket_service.py +++ b/openhands/integrations/bitbucket/bitbucket_service.py @@ -13,6 +13,7 @@ from openhands.integrations.service_types import ( GitService, InstallationsService, OwnerType, + PaginatedBranchesResponse, ProviderType, Repository, RequestMethod, @@ -551,6 +552,83 @@ class BitBucketService(BaseGitService, GitService, InstallationsService): return branches + async def get_paginated_branches( + self, repository: str, page: int = 1, per_page: int = 30 + ) -> PaginatedBranchesResponse: + """Get branches for a repository with pagination.""" + # Extract owner and repo from the repository string (e.g., "owner/repo") + parts = repository.split('/') + if len(parts) < 2: + raise ValueError(f'Invalid repository name: {repository}') + + owner = parts[-2] + repo = parts[-1] + + url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches' + + params = { + 'pagelen': per_page, + 'page': page, + 'sort': '-target.date', # Sort by most recent commit date, descending + } + + response, _ = await self._make_request(url, params) + + branches = [] + for branch in response.get('values', []): + branches.append( + Branch( + name=branch.get('name', ''), + commit_sha=branch.get('target', {}).get('hash', ''), + protected=False, # Bitbucket doesn't expose this in the API + last_push_date=branch.get('target', {}).get('date', None), + ) + ) + + # Bitbucket provides pagination info in the response + has_next_page = response.get('next') is not None + total_count = response.get('size') # Total number of items + + return PaginatedBranchesResponse( + branches=branches, + has_next_page=has_next_page, + current_page=page, + per_page=per_page, + total_count=total_count, + ) + + async def search_branches( + self, repository: str, query: str, per_page: int = 30 + ) -> list[Branch]: + """Search branches by name using Bitbucket API with `q` param.""" + parts = repository.split('/') + if len(parts) < 2: + raise ValueError(f'Invalid repository name: {repository}') + + owner = parts[-2] + repo = parts[-1] + + url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches' + # Bitbucket filtering: name ~ "query" + params = { + 'pagelen': per_page, + 'q': f'name~"{query}"', + 'sort': '-target.date', + } + response, _ = await self._make_request(url, params) + + branches: list[Branch] = [] + for branch in response.get('values', []): + branches.append( + Branch( + name=branch.get('name', ''), + commit_sha=branch.get('target', {}).get('hash', ''), + protected=False, + last_push_date=branch.get('target', {}).get('date', None), + ) + ) + return branches + async def create_pr( self, repo_name: str, diff --git a/openhands/integrations/github/github_service.py b/openhands/integrations/github/github_service.py index d613dd9acc..4c00ca1c5b 100644 --- a/openhands/integrations/github/github_service.py +++ b/openhands/integrations/github/github_service.py @@ -12,6 +12,7 @@ from openhands.integrations.github.queries import ( get_review_threads_graphql_query, get_thread_comments_graphql_query, get_thread_from_comment_graphql_query, + search_branches_graphql_query, suggested_task_issue_graphql_query, suggested_task_pr_graphql_query, ) @@ -22,6 +23,7 @@ from openhands.integrations.service_types import ( GitService, InstallationsService, OwnerType, + PaginatedBranchesResponse, ProviderType, Repository, RequestMethod, @@ -252,6 +254,7 @@ class GitHubService(BaseGitService, GitService, InstallationsService): else OwnerType.USER ), link_header=link_header, + main_branch=repo.get('default_branch'), ) async def get_paginated_repos( @@ -619,6 +622,109 @@ class GitHubService(BaseGitService, GitService, InstallationsService): return all_branches + async def get_paginated_branches( + self, repository: str, page: int = 1, per_page: int = 30 + ) -> PaginatedBranchesResponse: + """Get branches for a repository with pagination""" + url = f'{self.BASE_URL}/repos/{repository}/branches' + + params = {'per_page': str(per_page), 'page': str(page)} + response, headers = await self._make_request(url, params) + + branches: list[Branch] = [] + for branch_data in response: + # Extract the last commit date if available + last_push_date = None + if branch_data.get('commit') and branch_data['commit'].get('commit'): + commit_info = branch_data['commit']['commit'] + if commit_info.get('committer') and commit_info['committer'].get( + 'date' + ): + last_push_date = commit_info['committer']['date'] + + branch = Branch( + name=branch_data.get('name'), + commit_sha=branch_data.get('commit', {}).get('sha', ''), + protected=branch_data.get('protected', False), + last_push_date=last_push_date, + ) + branches.append(branch) + + # Parse Link header to determine if there's a next page + has_next_page = False + if 'Link' in headers: + link_header = headers['Link'] + has_next_page = 'rel="next"' in link_header + + return PaginatedBranchesResponse( + branches=branches, + has_next_page=has_next_page, + current_page=page, + per_page=per_page, + total_count=None, # GitHub doesn't provide total count in branch API + ) + + async def search_branches( + self, repository: str, query: str, per_page: int = 30 + ) -> list[Branch]: + """Search branches by name using GitHub GraphQL with a partial query.""" + # Require a non-empty query + if not query: + return [] + + # Clamp per_page to GitHub GraphQL limits + per_page = min(max(per_page, 1), 100) + + # Extract owner and repo name from the repository string + parts = repository.split('/') + if len(parts) < 2: + return [] + owner, name = parts[-2], parts[-1] + + variables = { + 'owner': owner, + 'name': name, + 'query': query or '', + 'perPage': per_page, + } + + try: + result = await self.execute_graphql_query( + search_branches_graphql_query, variables + ) + except Exception as e: + logger.warning(f'Failed to search for branches: {e}') + # Fallback to empty result on any GraphQL error + return [] + + repo = result.get('data', {}).get('repository') + if not repo or not repo.get('refs'): + return [] + + branches: list[Branch] = [] + for node in repo['refs'].get('nodes', []): + bname = node.get('name') or '' + target = node.get('target') or {} + typename = target.get('__typename') + commit_sha = '' + last_push_date = None + if typename == 'Commit': + commit_sha = target.get('oid', '') or '' + last_push_date = target.get('committedDate') + + protected = node.get('branchProtectionRule') is not None + + branches.append( + Branch( + name=bname, + commit_sha=commit_sha, + protected=protected, + last_push_date=last_push_date, + ) + ) + + return branches + async def create_pr( self, repo_name: str, diff --git a/openhands/integrations/github/queries.py b/openhands/integrations/github/queries.py index a8b2739846..ffa6596b5e 100644 --- a/openhands/integrations/github/queries.py +++ b/openhands/integrations/github/queries.py @@ -122,3 +122,32 @@ query ($threadId: ID!, $page: Int = 50, $after: String) { } } """ + +# Search branches in a repository by partial name using GitHub GraphQL. +# This leverages the `refs` connection with: +# - refPrefix: "refs/heads/" to restrict to branches +# - query: partial branch name provided by the user +# - first: pagination size (clamped by caller to GitHub limits) +search_branches_graphql_query = """ + query SearchBranches($owner: String!, $name: String!, $query: String!, $perPage: Int!) { + repository(owner: $owner, name: $name) { + refs( + refPrefix: "refs/heads/", + query: $query, + first: $perPage, + orderBy: { field: ALPHABETICAL, direction: ASC } + ) { + nodes { + name + target { + __typename + ... on Commit { + oid + committedDate + } + } + } + } + } + } +""" diff --git a/openhands/integrations/gitlab/gitlab_service.py b/openhands/integrations/gitlab/gitlab_service.py index 2e4c6328f7..485cf37a08 100644 --- a/openhands/integrations/gitlab/gitlab_service.py +++ b/openhands/integrations/gitlab/gitlab_service.py @@ -12,6 +12,7 @@ from openhands.integrations.service_types import ( Comment, GitService, OwnerType, + PaginatedBranchesResponse, ProviderType, Repository, RequestMethod, @@ -265,6 +266,7 @@ class GitLabService(BaseGitService, GitService): else OwnerType.USER ), link_header=link_header, + main_branch=repo.get('default_branch'), ) def _parse_gitlab_url(self, url: str) -> str | None: @@ -577,6 +579,68 @@ class GitLabService(BaseGitService, GitService): return all_branches + async def get_paginated_branches( + self, repository: str, page: int = 1, per_page: int = 30 + ) -> PaginatedBranchesResponse: + """Get branches for a repository with pagination""" + encoded_name = repository.replace('/', '%2F') + url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches' + + params = {'per_page': str(per_page), 'page': str(page)} + response, headers = await self._make_request(url, params) + + branches: list[Branch] = [] + for branch_data in response: + branch = Branch( + name=branch_data.get('name'), + commit_sha=branch_data.get('commit', {}).get('id', ''), + protected=branch_data.get('protected', False), + last_push_date=branch_data.get('commit', {}).get('committed_date'), + ) + branches.append(branch) + + # Parse pagination headers + has_next_page = False + total_count = None + + if 'X-Next-Page' in headers and headers['X-Next-Page']: + has_next_page = True + if 'X-Total' in headers: + try: + total_count = int(headers['X-Total']) + except (ValueError, TypeError): + pass + + return PaginatedBranchesResponse( + branches=branches, + has_next_page=has_next_page, + current_page=page, + per_page=per_page, + total_count=total_count, + ) + + async def search_branches( + self, repository: str, query: str, per_page: int = 30 + ) -> list[Branch]: + """Search branches using GitLab API which supports `search` param.""" + encoded_name = repository.replace('/', '%2F') + url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches' + + params = {'per_page': str(per_page), 'search': query} + response, _ = await self._make_request(url, params) + + branches: list[Branch] = [] + for branch_data in response: + branches.append( + Branch( + name=branch_data.get('name'), + commit_sha=branch_data.get('commit', {}).get('id', ''), + protected=branch_data.get('protected', False), + last_push_date=branch_data.get('commit', {}).get('committed_date'), + ) + ) + return branches + async def create_mr( self, id: int | str, diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index c0bff80581..b25530dcbc 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -26,6 +26,7 @@ from openhands.integrations.service_types import ( GitService, InstallationsService, MicroagentParseError, + PaginatedBranchesResponse, ProviderType, Repository, ResourceNotFoundError, @@ -256,6 +257,33 @@ class ProviderHandler: return tasks + async def search_branches( + self, + selected_provider: ProviderType | None, + repository: str, + query: str, + per_page: int = 30, + ) -> list[Branch]: + """Search for branches within a repository using the appropriate provider service.""" + if selected_provider: + service = self._get_service(selected_provider) + try: + return await service.search_branches(repository, query, per_page) + except Exception as e: + logger.warning( + f'Error searching branches from selected provider {selected_provider}: {e}' + ) + return [] + + # If provider not specified, determine provider by verifying repository access + try: + repo_details = await self.verify_repo_provider(repository) + service = self._get_service(repo_details.git_provider) + return await service.search_branches(repository, query, per_page) + except Exception as e: + logger.warning(f'Error searching branches for {repository}: {e}') + return [] + async def search_repositories( self, selected_provider: ProviderType | None, @@ -442,24 +470,27 @@ class ProviderHandler: raise AuthenticationError(f'Unable to access repo {repository}') async def get_branches( - self, repository: str, specified_provider: ProviderType | None = None - ) -> list[Branch]: + self, + repository: str, + specified_provider: ProviderType | None = None, + page: int = 1, + per_page: int = 30, + ) -> PaginatedBranchesResponse: """Get branches for a repository Args: repository: The repository name specified_provider: Optional provider type to use + page: Page number for pagination (default: 1) + per_page: Number of branches per page (default: 30) Returns: - A list of branches for the repository + A paginated response with branches for the repository """ - all_branches: list[Branch] = [] - if specified_provider: try: service = self._get_service(specified_provider) - branches = await service.get_branches(repository) - return branches + return await service.get_paginated_branches(repository, page, per_page) except Exception as e: logger.warning( f'Error fetching branches from {specified_provider}: {e}' @@ -468,31 +499,19 @@ class ProviderHandler: for provider in self.provider_tokens: try: service = self._get_service(provider) - branches = await service.get_branches(repository) - all_branches.extend(branches) - # If we found branches, no need to check other providers - if all_branches: - break + return await service.get_paginated_branches(repository, page, per_page) except Exception as e: logger.warning(f'Error fetching branches from {provider}: {e}') - # Sort branches by last push date (newest first) - all_branches.sort( - key=lambda b: b.last_push_date if b.last_push_date else '', reverse=True + # Return empty response if no provider worked + return PaginatedBranchesResponse( + branches=[], + has_next_page=False, + current_page=page, + per_page=per_page, + total_count=0, ) - # Move main/master branch to the top if it exists - main_branches = [] - other_branches = [] - - for branch in all_branches: - if branch.name.lower() in ['main', 'master']: - main_branches.append(branch) - else: - other_branches.append(branch) - - return main_branches + other_branches - async def get_microagents(self, repository: str) -> list[MicroagentResponse]: """Get microagents from a repository using the appropriate service. diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py index 8020c88e97..669f70258f 100644 --- a/openhands/integrations/service_types.py +++ b/openhands/integrations/service_types.py @@ -130,6 +130,14 @@ class Branch(BaseModel): last_push_date: str | None = None # ISO 8601 format date string +class PaginatedBranchesResponse(BaseModel): + branches: list[Branch] + has_next_page: bool + current_page: int + per_page: int + total_count: int | None = None # Some APIs don't provide total count + + class Repository(BaseModel): id: str full_name: str @@ -511,6 +519,16 @@ class GitService(Protocol): async def get_branches(self, repository: str) -> list[Branch]: """Get branches for a repository""" + async def get_paginated_branches( + self, repository: str, page: int = 1, per_page: int = 30 + ) -> PaginatedBranchesResponse: + """Get branches for a repository with pagination""" + + async def search_branches( + self, repository: str, query: str, per_page: int = 30 + ) -> list[Branch]: + """Search for branches within a repository""" + async def get_microagents(self, repository: str) -> list[MicroagentResponse]: """Get microagents from a repository""" ... diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py index 024190c88c..db0f0255b4 100644 --- a/openhands/server/routes/git.py +++ b/openhands/server/routes/git.py @@ -13,6 +13,7 @@ from openhands.integrations.provider import ( from openhands.integrations.service_types import ( AuthenticationError, Branch, + PaginatedBranchesResponse, ProviderType, Repository, SuggestedTask, @@ -163,6 +164,49 @@ async def search_repositories( raise AuthenticationError('Git provider token required.') +@app.get('/search/branches', response_model=list[Branch]) +async def search_branches( + repository: str, + query: str, + per_page: int = 30, + selected_provider: ProviderType | None = None, + provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens), + access_token: SecretStr | None = Depends(get_access_token), + user_id: str | None = Depends(get_user_id), +) -> list[Branch] | JSONResponse: + if provider_tokens: + client = ProviderHandler( + provider_tokens=provider_tokens, + external_auth_token=access_token, + external_auth_id=user_id, + ) + try: + branches: list[Branch] = await client.search_branches( + selected_provider, repository, query, per_page + ) + return branches + + except AuthenticationError as e: + return JSONResponse( + content=str(e), + status_code=status.HTTP_401_UNAUTHORIZED, + ) + + except UnknownException as e: + return JSONResponse( + content=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + logger.info( + f'Returning 401 Unauthorized - Git provider token required for user_id: {user_id}' + ) + return JSONResponse( + content='Git provider token required.', + status_code=status.HTTP_401_UNAUTHORIZED, + ) + + @app.get('/suggested-tasks', response_model=list[SuggestedTask]) async def get_suggested_tasks( provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens), @@ -192,28 +236,34 @@ async def get_suggested_tasks( raise AuthenticationError('No providers set.') -@app.get('/repository/branches', response_model=list[Branch]) +@app.get('/repository/branches', response_model=PaginatedBranchesResponse) async def get_repository_branches( repository: str, + page: int = 1, + per_page: int = 30, provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens), access_token: SecretStr | None = Depends(get_access_token), user_id: str | None = Depends(get_user_id), -) -> list[Branch] | JSONResponse: +) -> PaginatedBranchesResponse | JSONResponse: """Get branches for a repository. Args: repository: The repository name in the format 'owner/repo' + page: Page number for pagination (default: 1) + per_page: Number of branches per page (default: 30) Returns: - A list of branches for the repository + A paginated response with branches for the repository """ if provider_tokens: client = ProviderHandler( provider_tokens=provider_tokens, external_auth_token=access_token ) try: - branches: list[Branch] = await client.get_branches(repository) - return branches + branches_response: PaginatedBranchesResponse = await client.get_branches( + repository, page=page, per_page=per_page + ) + return branches_response except UnknownException as e: return JSONResponse( diff --git a/tests/unit/integrations/bitbucket/test_bitbucket_branches.py b/tests/unit/integrations/bitbucket/test_bitbucket_branches.py new file mode 100644 index 0000000000..61574aa324 --- /dev/null +++ b/tests/unit/integrations/bitbucket/test_bitbucket_branches.py @@ -0,0 +1,84 @@ +from unittest.mock import patch + +import pytest +from pydantic import SecretStr + +from openhands.integrations.bitbucket.bitbucket_service import BitBucketService +from openhands.integrations.service_types import Branch, PaginatedBranchesResponse + + +@pytest.mark.asyncio +async def test_get_paginated_branches_bitbucket_parsing_and_pagination(): + service = BitBucketService(token=SecretStr('t')) + + mock_response = { + 'values': [ + { + 'name': 'main', + 'target': {'hash': 'abc', 'date': '2024-01-01T00:00:00Z'}, + }, + { + 'name': 'feature/x', + 'target': {'hash': 'def', 'date': '2024-01-02T00:00:00Z'}, + }, + ], + 'next': 'https://api.bitbucket.org/2.0/repositories/w/r/refs/branches?page=3', + 'size': 123, + } + + with patch.object(service, '_make_request', return_value=(mock_response, {})): + res = await service.get_paginated_branches('w/r', page=2, per_page=2) + + assert isinstance(res, PaginatedBranchesResponse) + assert res.has_next_page is True + assert res.current_page == 2 + assert res.per_page == 2 + assert res.total_count == 123 + assert res.branches == [ + Branch( + name='main', + commit_sha='abc', + protected=False, + last_push_date='2024-01-01T00:00:00Z', + ), + Branch( + name='feature/x', + commit_sha='def', + protected=False, + last_push_date='2024-01-02T00:00:00Z', + ), + ] + + +@pytest.mark.asyncio +async def test_search_branches_bitbucket_filters_by_name_contains(): + service = BitBucketService(token=SecretStr('t')) + + mock_response = { + 'values': [ + { + 'name': 'bugfix/issue-1', + 'target': {'hash': 'hhh', 'date': '2024-01-10T10:00:00Z'}, + } + ] + } + + with patch.object(service, '_make_request', return_value=(mock_response, {})) as m: + branches = await service.search_branches('w/r', query='bugfix', per_page=15) + + args, kwargs = m.call_args + url = args[0] + params = args[1] + assert 'refs/branches' in url + assert params['pagelen'] == 15 + assert params['q'] == 'name~"bugfix"' + assert params['sort'] == '-target.date' + + assert branches == [ + Branch( + name='bugfix/issue-1', + commit_sha='hhh', + protected=False, + last_push_date='2024-01-10T10:00:00Z', + ) + ] diff --git a/tests/unit/integrations/github/test_github_branches.py b/tests/unit/integrations/github/test_github_branches.py new file mode 100644 index 0000000000..7aa010016f --- /dev/null +++ b/tests/unit/integrations/github/test_github_branches.py @@ -0,0 +1,168 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import SecretStr + +from openhands.integrations.github.github_service import GitHubService +from openhands.integrations.service_types import Branch, PaginatedBranchesResponse + + +@pytest.mark.asyncio +async def test_get_paginated_branches_github_basic_next_page(): + service = GitHubService(token=SecretStr('t')) + + mock_response = [ + { + 'name': 'main', + 'commit': { + 'sha': 'abc123', + 'commit': {'committer': {'date': '2024-01-01T12:00:00Z'}}, + }, + 'protected': True, + }, + { + 'name': 'feature/foo', + 'commit': { + 'sha': 'def456', + 'commit': {'committer': {'date': '2024-01-02T15:30:00Z'}}, + }, + 'protected': False, + }, + ] + headers = { + # Include rel="next" to indicate there is another page + 'Link': '; rel="next"' + } + + with patch.object(service, '_make_request', return_value=(mock_response, headers)): + result = await service.get_paginated_branches('owner/repo', page=2, per_page=2) + + assert isinstance(result, PaginatedBranchesResponse) + assert result.current_page == 2 + assert result.per_page == 2 + assert result.has_next_page is True + assert result.total_count is None # GitHub does not provide total count + assert len(result.branches) == 2 + + b0, b1 = result.branches + assert isinstance(b0, Branch) and isinstance(b1, Branch) + assert b0.name == 'main' + assert b0.commit_sha == 'abc123' + assert b0.protected is True + assert b0.last_push_date == '2024-01-01T12:00:00Z' + assert b1.name == 'feature/foo' + assert b1.commit_sha == 'def456' + assert b1.protected is False + assert b1.last_push_date == '2024-01-02T15:30:00Z' + + +@pytest.mark.asyncio +async def test_get_paginated_branches_github_no_next_page(): + service = GitHubService(token=SecretStr('t')) + + mock_response = [ + { + 'name': 'dev', + 'commit': { + 'sha': 'zzz999', + 'commit': {'committer': {'date': '2024-01-03T00:00:00Z'}}, + }, + 'protected': False, + } + ] + headers = { + # No rel="next" – should be treated as last page + 'Link': '; rel="prev"' + } + + with patch.object(service, '_make_request', return_value=(mock_response, headers)): + result = await service.get_paginated_branches('owner/repo', page=1, per_page=1) + assert result.has_next_page is False + assert len(result.branches) == 1 + assert result.branches[0].name == 'dev' + + +@pytest.mark.asyncio +async def test_search_branches_github_success_and_variables(): + service = GitHubService(token=SecretStr('t')) + + # Prepare a fake GraphQL response structure + graphql_result = { + 'data': { + 'repository': { + 'refs': { + 'nodes': [ + { + 'name': 'feature/bar', + 'target': { + '__typename': 'Commit', + 'oid': 'aaa111', + 'committedDate': '2024-01-05T10:00:00Z', + }, + 'branchProtectionRule': {}, # indicates protected + }, + { + 'name': 'chore/update', + 'target': { + '__typename': 'Tag', + 'oid': 'should_be_ignored_for_commit', + }, + 'branchProtectionRule': None, + }, + ] + } + } + } + } + + exec_mock = AsyncMock(return_value=graphql_result) + with patch.object(service, 'execute_graphql_query', exec_mock) as mock_exec: + branches = await service.search_branches('foo/bar', query='fe', per_page=999) + + # per_page should be clamped to <= 100 when passed to GraphQL variables + args, kwargs = mock_exec.call_args + _query = args[0] + variables = args[1] + assert variables['owner'] == 'foo' + assert variables['name'] == 'bar' + assert variables['query'] == 'fe' + assert 1 <= variables['perPage'] <= 100 + + assert len(branches) == 2 + b0, b1 = branches + assert b0.name == 'feature/bar' + assert b0.commit_sha == 'aaa111' + assert b0.protected is True + assert b0.last_push_date == '2024-01-05T10:00:00Z' + + # Non-commit target results in empty sha and no date + assert b1.name == 'chore/update' + assert b1.commit_sha == '' + assert b1.last_push_date is None + assert b1.protected is False + + +@pytest.mark.asyncio +async def test_search_branches_github_edge_cases(): + service = GitHubService(token=SecretStr('t')) + + # Empty query should return [] without issuing a GraphQL call + branches = await service.search_branches('foo/bar', query='') + assert branches == [] + + # Invalid repository string should return [] without calling GraphQL + exec_mock = AsyncMock() + with patch.object(service, 'execute_graphql_query', exec_mock): + branches = await service.search_branches('invalidrepo', query='q') + assert branches == [] + exec_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_search_branches_github_graphql_error_returns_empty(): + service = GitHubService(token=SecretStr('t')) + + exec_mock = AsyncMock(side_effect=Exception('Boom')) + with patch.object(service, 'execute_graphql_query', exec_mock): + branches = await service.search_branches('foo/bar', query='q') + assert branches == [] diff --git a/tests/unit/integrations/gitlab/test_gitlab_branches.py b/tests/unit/integrations/gitlab/test_gitlab_branches.py new file mode 100644 index 0000000000..e903aef3fd --- /dev/null +++ b/tests/unit/integrations/gitlab/test_gitlab_branches.py @@ -0,0 +1,119 @@ +from unittest.mock import patch + +import pytest +from pydantic import SecretStr + +from openhands.integrations.gitlab.gitlab_service import GitLabService +from openhands.integrations.service_types import Branch, PaginatedBranchesResponse + + +@pytest.mark.asyncio +async def test_get_paginated_branches_gitlab_headers_and_parsing(): + service = GitLabService(token=SecretStr('t')) + + mock_response = [ + { + 'name': 'main', + 'commit': {'id': 'abc', 'committed_date': '2024-01-01T00:00:00Z'}, + 'protected': True, + }, + { + 'name': 'dev', + 'commit': {'id': 'def', 'committed_date': '2024-01-02T00:00:00Z'}, + 'protected': False, + }, + ] + + headers = { + 'X-Next-Page': '3', # indicates has next page + 'X-Total': '42', + } + + with patch.object(service, '_make_request', return_value=(mock_response, headers)): + res = await service.get_paginated_branches('group/repo', page=2, per_page=2) + + assert isinstance(res, PaginatedBranchesResponse) + assert res.has_next_page is True + assert res.current_page == 2 + assert res.per_page == 2 + assert res.total_count == 42 + assert len(res.branches) == 2 + assert res.branches[0] == Branch( + name='main', + commit_sha='abc', + protected=True, + last_push_date='2024-01-01T00:00:00Z', + ) + assert res.branches[1] == Branch( + name='dev', + commit_sha='def', + protected=False, + last_push_date='2024-01-02T00:00:00Z', + ) + + +@pytest.mark.asyncio +async def test_get_paginated_branches_gitlab_no_next_or_total(): + service = GitLabService(token=SecretStr('t')) + + mock_response = [ + { + 'name': 'fix', + 'commit': {'id': 'zzz', 'committed_date': '2024-01-03T00:00:00Z'}, + 'protected': False, + } + ] + + headers = {} # No pagination headers; should be has_next_page False + + with patch.object(service, '_make_request', return_value=(mock_response, headers)): + res = await service.get_paginated_branches('group/repo', page=1, per_page=1) + assert res.has_next_page is False + assert res.total_count is None + assert len(res.branches) == 1 + assert res.branches[0].name == 'fix' + + +@pytest.mark.asyncio +async def test_search_branches_gitlab_uses_search_param(): + service = GitLabService(token=SecretStr('t')) + + mock_response = [ + { + 'name': 'feat/new', + 'commit': {'id': '111', 'committed_date': '2024-01-04T00:00:00Z'}, + 'protected': False, + }, + { + 'name': 'feature/xyz', + 'commit': {'id': '222', 'committed_date': '2024-01-05T00:00:00Z'}, + 'protected': True, + }, + ] + + with patch.object(service, '_make_request', return_value=(mock_response, {})) as m: + branches = await service.search_branches( + 'group/repo', query='feat', per_page=50 + ) + + # Verify parameters + args, kwargs = m.call_args + url = args[0] + params = args[1] + assert 'repository/branches' in url + assert params['per_page'] == '50' + assert params['search'] == 'feat' + + assert len(branches) == 2 + assert branches[0] == Branch( + name='feat/new', + commit_sha='111', + protected=False, + last_push_date='2024-01-04T00:00:00Z', + ) + assert branches[1] == Branch( + name='feature/xyz', + commit_sha='222', + protected=True, + last_push_date='2024-01-05T00:00:00Z', + ) From 83b9262379067b8eb4551228a455d699b43ba206 Mon Sep 17 00:00:00 2001 From: mamoodi Date: Thu, 28 Aug 2025 23:52:36 -0400 Subject: [PATCH 08/10] Add troubleshooting guide for linux timeout issue (#10685) --- README.md | 1 - docs/usage/troubleshooting/troubleshooting.mdx | 10 ++++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b38d55cfd..f5b9e4f475 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,6 @@ If you want to modify the OpenHands source code, check out [Development.md](http Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/usage/troubleshooting) can help. ## 📖 Documentation - Ask DeepWiki To learn more about the project, and for tips on using OpenHands, check out our [documentation](https://docs.all-hands.dev/usage/getting-started). diff --git a/docs/usage/troubleshooting/troubleshooting.mdx b/docs/usage/troubleshooting/troubleshooting.mdx index 2b4c2bf23b..81c3604dcb 100644 --- a/docs/usage/troubleshooting/troubleshooting.mdx +++ b/docs/usage/troubleshooting/troubleshooting.mdx @@ -38,6 +38,16 @@ 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. +### On Linux, Getting ConnectTimeout Error + +**Description** + +When running on Linux, you might run into the error `ERROR:root:: timed out`. + +**Resolution** + +* Add the `--network host` to the docker run command. + ### Internal Server Error. Ports are not available **Description** From e47bcf31e4e6ebc98ec691df11d6e30b73669d3c Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Fri, 29 Aug 2025 02:38:03 -0400 Subject: [PATCH 09/10] [Bug, GitLab]: fix missing context in cloud resolver (#10509) Co-authored-by: openhands --- .../integrations/github/github_service.py | 8 - .../integrations/gitlab/gitlab_service.py | 144 ++++++++++++------ openhands/integrations/service_types.py | 8 + ...issue_comment_conversation_instructions.j2 | 22 --- .../resolver/gitlab/issue_comment_prompt.j2 | 1 - .../gitlab/issue_conversation_instructions.j2 | 41 +++++ ...issue_labeled_conversation_instructions.j2 | 17 --- .../resolver/gitlab/issue_labeled_prompt.j2 | 1 - .../templates/resolver/gitlab/issue_prompt.j2 | 5 + .../mr_update_conversation_instructions.j2 | 21 ++- 10 files changed, 169 insertions(+), 99 deletions(-) delete mode 100644 openhands/integrations/templates/resolver/gitlab/issue_comment_conversation_instructions.j2 delete mode 100644 openhands/integrations/templates/resolver/gitlab/issue_comment_prompt.j2 create mode 100644 openhands/integrations/templates/resolver/gitlab/issue_conversation_instructions.j2 delete mode 100644 openhands/integrations/templates/resolver/gitlab/issue_labeled_conversation_instructions.j2 delete mode 100644 openhands/integrations/templates/resolver/gitlab/issue_labeled_prompt.j2 create mode 100644 openhands/integrations/templates/resolver/gitlab/issue_prompt.j2 diff --git a/openhands/integrations/github/github_service.py b/openhands/integrations/github/github_service.py index 4c00ca1c5b..b062bfd974 100644 --- a/openhands/integrations/github/github_service.py +++ b/openhands/integrations/github/github_service.py @@ -1028,14 +1028,6 @@ class GitHubService(BaseGitService, GitService, InstallationsService): return self._process_raw_comments(all_thread_comments) - def _truncate_comment( - self, comment_body: str, max_comment_length: int = 500 - ) -> str: - """Truncate comment body to a maximum length.""" - if len(comment_body) > max_comment_length: - return comment_body[:max_comment_length] + '...' - return comment_body - def _process_raw_comments( self, comments_data: list, max_comments: int = 10 ) -> list[Comment]: diff --git a/openhands/integrations/gitlab/gitlab_service.py b/openhands/integrations/gitlab/gitlab_service.py index 485cf37a08..8c0589c3fa 100644 --- a/openhands/integrations/gitlab/gitlab_service.py +++ b/openhands/integrations/gitlab/gitlab_service.py @@ -756,79 +756,129 @@ class GitLabService(BaseGitService, GitService): # Parse the content to extract triggers from frontmatter return self._parse_microagent_content(response, file_path) - async def get_issue_comments( - self, project_id: str, issue_iid: int, limit: int = 100 + async def get_review_thread_comments( + self, project_id: str, issue_iid: int, discussion_id: str ) -> list[Comment]: - """Get the last n comments for a specific issue. + url = ( + f'{self.BASE_URL}/projects/{project_id}' + f'/merge_requests/{issue_iid}/discussions/{discussion_id}' + ) + + # Single discussion fetch; notes are returned inline. + response, _ = await self._make_request(url) + notes = response.get('notes') or [] + return self._process_raw_comments(notes) + + async def get_issue_or_mr_title_and_body( + self, project_id: str, issue_number: int, is_mr: bool = False + ) -> tuple[str, str]: + """Get the title and body of an issue or merge request. Args: - project_id: The GitLab project ID (can be numeric ID or URL-encoded path) - issue_iid: The issue internal ID (iid) in GitLab - limit: Maximum number of comments to retrieve (default: 100) + repository: Repository name in format 'owner/repo' or 'domain/owner/repo' + issue_number: The issue/MR IID within the project + is_mr: If True, treat as merge request; if False, treat as issue; + if None, try issue first then merge request (default behavior) Returns: - List of Comment objects, ordered by creation date (newest first) - - Raises: - UnknownException: If the request fails or the issue is not found + A tuple of (title, body) """ - # URL-encode the project_id if it contains special characters - if '/' in str(project_id): - encoded_project_id = str(project_id).replace('/', '%2F') - else: - encoded_project_id = str(project_id) + if is_mr: + url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{issue_number}' + response, _ = await self._make_request(url) + title = response.get('title') or '' + body = response.get('description') or '' + return title, body - url = f'{self.BASE_URL}/projects/{encoded_project_id}/issues/{issue_iid}/notes' + url = f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}' + response, _ = await self._make_request(url) + title = response.get('title') or '' + body = response.get('description') or '' + return title, body + async def get_issue_or_mr_comments( + self, + project_id: str, + issue_number: int, + max_comments: int = 10, + is_mr: bool = False, + ) -> list[Comment]: + """Get comments for an issue or merge request. + + Args: + repository: Repository name in format 'owner/repo' or 'domain/owner/repo' + issue_number: The issue/MR IID within the project + max_comments: Maximum number of comments to retrieve + is_pr: If True, treat as merge request; if False, treat as issue; + if None, try issue first then merge request (default behavior) + + Returns: + List of Comment objects ordered by creation date + """ all_comments: list[Comment] = [] page = 1 - per_page = min(limit, 100) # GitLab API max per_page is 100 + per_page = min(max_comments, 10) - while len(all_comments) < limit: - # Get comments with pagination, ordered by creation date descending + url = ( + f'{self.BASE_URL}/projects/{project_id}/merge_requests/{issue_number}/discussions' + if is_mr + else f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}/notes' + ) + + while len(all_comments) < max_comments: params = { 'per_page': per_page, 'page': page, 'order_by': 'created_at', - 'sort': 'desc', # Get newest comments first + 'sort': 'asc', } response, headers = await self._make_request(url, params) - - if not response: # No more comments + if not response: break - # Filter out system comments and convert to Comment objects - for comment_data in response: - if len(all_comments) >= limit: - break + if is_mr: + for discussions in response: + # Keep root level comments + all_comments.append(discussions['notes'][0]) + else: + all_comments.extend(response) - # Skip system-generated comments unless explicitly requested - if comment_data.get('system', False): - continue - - comment = Comment( - id=str(comment_data['id']), - body=comment_data['body'], - author=comment_data.get('author', {}).get('username', 'unknown'), - created_at=datetime.fromisoformat( - comment_data['created_at'].replace('Z', '+00:00') - ), - updated_at=datetime.fromisoformat( - comment_data['updated_at'].replace('Z', '+00:00') - ), - system=comment_data.get('system', False), - ) - all_comments.append(comment) - - # Check if we have more pages link_header = headers.get('Link', '') - if 'rel="next"' not in link_header or len(all_comments) >= limit: + if 'rel="next"' not in link_header: break page += 1 - return all_comments + return self._process_raw_comments(all_comments) + + def _process_raw_comments( + self, comments: list, max_comments: int = 10 + ) -> list[Comment]: + """Helper method to fetch comments from a given URL with pagination.""" + all_comments: list[Comment] = [] + for comment_data in comments: + comment = Comment( + id=str(comment_data.get('id', 'unknown')), + body=self._truncate_comment(comment_data.get('body', '')), + author=comment_data.get('author', {}).get('username', 'unknown'), + created_at=datetime.fromisoformat( + comment_data.get('created_at', '').replace('Z', '+00:00') + ) + if comment_data.get('created_at') + else datetime.fromtimestamp(0), + updated_at=datetime.fromisoformat( + comment_data.get('updated_at', '').replace('Z', '+00:00') + ) + if comment_data.get('updated_at') + else datetime.fromtimestamp(0), + system=comment_data.get('system', False), + ) + all_comments.append(comment) + + # Sort comments by creation date and return the most recent ones + all_comments.sort(key=lambda c: c.created_at) + return all_comments[-max_comments:] async def is_pr_open(self, repository: str, pr_number: int) -> bool: """Check if a GitLab merge request is still active (not closed/merged). diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py index 669f70258f..b5734b9f35 100644 --- a/openhands/integrations/service_types.py +++ b/openhands/integrations/service_types.py @@ -454,6 +454,14 @@ class BaseGitService(ABC): return microagents + def _truncate_comment( + self, comment_body: str, max_comment_length: int = 500 + ) -> str: + """Truncate comment body to a maximum length.""" + if len(comment_body) > max_comment_length: + return comment_body[:max_comment_length] + '...' + return comment_body + class InstallationsService(Protocol): async def get_installations(self) -> list[str]: diff --git a/openhands/integrations/templates/resolver/gitlab/issue_comment_conversation_instructions.j2 b/openhands/integrations/templates/resolver/gitlab/issue_comment_conversation_instructions.j2 deleted file mode 100644 index e5738add56..0000000000 --- a/openhands/integrations/templates/resolver/gitlab/issue_comment_conversation_instructions.j2 +++ /dev/null @@ -1,22 +0,0 @@ -You are requested to fix issue number #{{ issue_number }} in a repository. - -A comment on the issue has been addressed to you. - -# Steps to Handle the Comment - -1. Address the comment. Use the $GITLAB_TOKEN and GitLab API to read issue title, body, and comments if you need more context -2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed -3. Run the tests, and if they pass you are done! -4. You do NOT need to write new tests if there are only changes to documentation or configuration files. - -When you're done, make sure to - -1. Re-read the issue title, body, and comments and make sure that you have successfully implemented all requirements. -2. Create a new branch using `openhands/` as a prefix (e.g `openhands/update-readme`) -3. Commit your changes with a clear commit message -4. Push the branch to GitLab -5. Use the `create_mr` tool to open a new MR -6. The MR description should: - - Follow the repository's MR template (check `.gitlab/merge_request_templates/` or `.github/pull_request_template.md` if it exists) - - Mention that it "fixes" or "closes" the issue number - - Include all required sections from the template diff --git a/openhands/integrations/templates/resolver/gitlab/issue_comment_prompt.j2 b/openhands/integrations/templates/resolver/gitlab/issue_comment_prompt.j2 deleted file mode 100644 index a84b0bdbc7..0000000000 --- a/openhands/integrations/templates/resolver/gitlab/issue_comment_prompt.j2 +++ /dev/null @@ -1 +0,0 @@ -{{ issue_comment }} diff --git a/openhands/integrations/templates/resolver/gitlab/issue_conversation_instructions.j2 b/openhands/integrations/templates/resolver/gitlab/issue_conversation_instructions.j2 new file mode 100644 index 0000000000..61e10ca7b8 --- /dev/null +++ b/openhands/integrations/templates/resolver/gitlab/issue_conversation_instructions.j2 @@ -0,0 +1,41 @@ +{% if issue_number %} +You are requested to fix issue #{{ issue_number }}: "{{ issue_title }}" in a repository. +A comment on the issue has been addressed to you. +{% else %} +Your task is to fix the issue: "{{ issue_title }}". +{% endif %} + +# Issue Body +{{ issue_body }} + +{% if comments %} +# Previous Comments +For reference, here are the previous comments on the issue: + +{% for comment in comments %} +- @{{ comment.author }} said: +{{ comment.body }} +{% if not loop.last %}\n\n{% endif %} +{% endfor %} +{% endif %} + +# Guidelines + +1. Review the task carefully. +2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed +3. Run the tests, and if they pass you are done! +4. You do NOT need to write new tests if there are only changes to documentation or configuration files. + +# Final Checklist +Re-read the issue title, body, and comments and make sure that you have successfully implemented all requirements. + +Use the $GITLAB_TOKEN and GitLab APIs to + +1. Create a new branch using `openhands/` as a prefix (e.g `openhands/update-readme`) +2. Commit your changes with a clear commit message +3. Push the branch to GitLab +4. Use the `create_mr` tool to open a new MR +5. The MR description should: + - Follow the repository's MR template (check `.gitlab/merge_request_templates/` or `.github/pull_request_template.md` if it exists) + - Mention that it "fixes" or "closes" the issue number + - Include all required sections from the template diff --git a/openhands/integrations/templates/resolver/gitlab/issue_labeled_conversation_instructions.j2 b/openhands/integrations/templates/resolver/gitlab/issue_labeled_conversation_instructions.j2 deleted file mode 100644 index 24b957d862..0000000000 --- a/openhands/integrations/templates/resolver/gitlab/issue_labeled_conversation_instructions.j2 +++ /dev/null @@ -1,17 +0,0 @@ -Your tasking is to fix an issue in your repository. Do the following - -1. Read the issue body and comments using the $GITLAB_TOKEN and GitLab API -2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed -3. Run the tests, and if they pass you are done! -4. You do NOT need to write new tests if there are only changes to documentation or configuration files. - -When you're done, make sure to - -1. Create a new branch with a descriptive name (e.g., `openhands/fix-issue-123`) -2. Commit your changes with a clear commit message -3. Push the branch to GitLab -4. Use the `create_mr` tool to open a new MR -5. The MR description should: - - Follow the repository's MR template (check `.gitlab/merge_request_templates/` or `.github/pull_request_template.md` if it exists) - - Mention that it "fixes" or "closes" the issue number - - Include all required sections from the template diff --git a/openhands/integrations/templates/resolver/gitlab/issue_labeled_prompt.j2 b/openhands/integrations/templates/resolver/gitlab/issue_labeled_prompt.j2 deleted file mode 100644 index 358ed79a74..0000000000 --- a/openhands/integrations/templates/resolver/gitlab/issue_labeled_prompt.j2 +++ /dev/null @@ -1 +0,0 @@ -Please fix issue number #{{ issue_number }} in your repository. diff --git a/openhands/integrations/templates/resolver/gitlab/issue_prompt.j2 b/openhands/integrations/templates/resolver/gitlab/issue_prompt.j2 new file mode 100644 index 0000000000..4fb91742dd --- /dev/null +++ b/openhands/integrations/templates/resolver/gitlab/issue_prompt.j2 @@ -0,0 +1,5 @@ +{% if issue_comment %} +{{ issue_comment }} +{% else %} +Please fix issue number #{{ issue_number }}. +{% endif %} diff --git a/openhands/integrations/templates/resolver/gitlab/mr_update_conversation_instructions.j2 b/openhands/integrations/templates/resolver/gitlab/mr_update_conversation_instructions.j2 index c6735e1cf9..5025edcebe 100644 --- a/openhands/integrations/templates/resolver/gitlab/mr_update_conversation_instructions.j2 +++ b/openhands/integrations/templates/resolver/gitlab/mr_update_conversation_instructions.j2 @@ -1,7 +1,22 @@ -You are checked out to branch {{ branch_name }}, which has an open MR #{{ mr_number }}. -A comment on the MR has been addressed to you. Do NOT respond to this comment via the GitLab API. +You are checked out to branch {{ branch_name }}, which has an open MR #{{ mr_number }}: "{{ mr_title }}". +A comment on the MR has been addressed to you. -{% if file_location %} The comment is in the file `{{ file_location }}` on line #{{ line_number }}{% endif %}. +# MR Description +{{ mr_body }} + +{% if comments %} +You may find these other comments relevant: +{% for comment in comments %} +- @{{ comment.author }} said at {{ comment.created_at }}: +{{ comment.body }} +{% if not loop.last %}\n\n{% endif %} +{% endfor %} +{% endif %} + +{% if file_location %} +# Comment location +The comment is in the file `{{ file_location }}` on line #{{ line_number }} +{% endif %}. # Steps to Handle the Comment From ab2da611f5107ff49567a68cf5b7bfc2f14c0cf9 Mon Sep 17 00:00:00 2001 From: "Ryan H. Tran" Date: Fri, 29 Aug 2025 17:57:50 +0700 Subject: [PATCH 10/10] fix: validate `task_list` schema for task tracker (#10624) --- .../codeact_agent/function_calling.py | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/openhands/agenthub/codeact_agent/function_calling.py b/openhands/agenthub/codeact_agent/function_calling.py index ca0c221bda..34875645bd 100644 --- a/openhands/agenthub/codeact_agent/function_calling.py +++ b/openhands/agenthub/codeact_agent/function_calling.py @@ -263,9 +263,36 @@ def response_to_actions( f'Missing required argument "task_list" for "plan" command in tool call {tool_call.function.name}' ) + raw_task_list = arguments.get('task_list', []) + if not isinstance(raw_task_list, list): + raise FunctionCallValidationError( + f'Invalid format for "task_list". Expected a list but got {type(raw_task_list)}.' + ) + + # Normalize task_list to ensure it's always a list of dictionaries + normalized_task_list = [] + for i, task in enumerate(raw_task_list): + if isinstance(task, dict): + # Task is already in correct format, ensure required fields exist + normalized_task = { + 'id': task.get('id', f'task-{i + 1}'), + 'title': task.get('title', 'Untitled task'), + 'status': task.get('status', 'todo'), + 'notes': task.get('notes', ''), + } + else: + # Unexpected format, raise validation error + logger.warning( + f'Unexpected task format in task_list: {type(task)} - {task}' + ) + raise FunctionCallValidationError( + f'Unexpected task format in task_list: {type(task)}. Each task shoud be a dictionary.' + ) + normalized_task_list.append(normalized_task) + action = TaskTrackingAction( command=arguments['command'], - task_list=arguments.get('task_list', []), + task_list=normalized_task_list, ) # ================================================