From 343b86429ef28fe2c5b4ecd08bfc34893678bcda Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Mon, 6 Jan 2025 14:22:52 -0500 Subject: [PATCH 01/17] Retrieve GitHub IDs more efficiently (#6074) Co-authored-by: openhands --- frontend/src/context/ws-client-provider.tsx | 9 +--- frontend/src/routes/_oh.app/route.tsx | 2 +- openhands/server/auth.py | 5 ++ openhands/server/listen_socket.py | 25 +++++---- .../server/routes/manage_conversations.py | 52 +++++++++---------- openhands/server/routes/settings.py | 13 ++--- .../conversation/conversation_store.py | 4 +- .../conversation/file_conversation_store.py | 4 +- .../data_models/conversation_metadata.py | 2 +- .../storage/settings/file_settings_store.py | 2 +- openhands/storage/settings/settings_store.py | 2 +- tests/unit/test_conversation.py | 2 +- 12 files changed, 58 insertions(+), 64 deletions(-) diff --git a/frontend/src/context/ws-client-provider.tsx b/frontend/src/context/ws-client-provider.tsx index 12e354cee0ff0..177a11e590324 100644 --- a/frontend/src/context/ws-client-provider.tsx +++ b/frontend/src/context/ws-client-provider.tsx @@ -62,16 +62,13 @@ const WsClientContext = React.createContext({ interface WsClientProviderProps { conversationId: string; - ghToken: string | null; } export function WsClientProvider({ - ghToken, conversationId, children, }: React.PropsWithChildren) { const sioRef = React.useRef(null); - const ghTokenRef = React.useRef(ghToken); const [status, setStatus] = React.useState( WsClientProviderStatus.DISCONNECTED, ); @@ -141,9 +138,6 @@ export function WsClientProvider({ sio = io(baseUrl, { transports: ["websocket"], - auth: { - github_token: ghToken || undefined, - }, query, }); sio.on("connect", handleConnect); @@ -153,7 +147,6 @@ export function WsClientProvider({ sio.on("disconnect", handleDisconnect); sioRef.current = sio; - ghTokenRef.current = ghToken; return () => { sio.off("connect", handleConnect); @@ -162,7 +155,7 @@ export function WsClientProvider({ sio.off("connect_failed", handleError); sio.off("disconnect", handleDisconnect); }; - }, [ghToken, conversationId]); + }, [conversationId]); React.useEffect( () => () => { diff --git a/frontend/src/routes/_oh.app/route.tsx b/frontend/src/routes/_oh.app/route.tsx index 4fd5dec22eec4..ab3384f951ec9 100644 --- a/frontend/src/routes/_oh.app/route.tsx +++ b/frontend/src/routes/_oh.app/route.tsx @@ -175,7 +175,7 @@ function AppContent() { } return ( - +
{renderMain()}
diff --git a/openhands/server/auth.py b/openhands/server/auth.py index 4b3fccdda7f11..b695cff89eab6 100644 --- a/openhands/server/auth.py +++ b/openhands/server/auth.py @@ -1,9 +1,14 @@ import jwt +from fastapi import Request from jwt.exceptions import InvalidTokenError from openhands.core.logger import openhands_logger as logger +def get_user_id(request: Request) -> int: + return getattr(request.state, 'github_user_id', 0) + + def get_sid_from_token(token: str, jwt_secret: str) -> str: """Retrieves the session id from a JWT token. diff --git a/openhands/server/listen_socket.py b/openhands/server/listen_socket.py index 4bd7b8071960a..4bb81aad66767 100644 --- a/openhands/server/listen_socket.py +++ b/openhands/server/listen_socket.py @@ -1,6 +1,6 @@ from urllib.parse import parse_qs -from github import Github +import jwt from socketio.exceptions import ConnectionRefusedError from openhands.core.logger import openhands_logger as logger @@ -18,7 +18,6 @@ from openhands.server.session.manager import ConversationDoesNotExistError from openhands.server.shared import config, openhands_config, session_manager, sio from openhands.server.types import AppMode -from openhands.utils.async_utils import call_sync_from_async @sio.event @@ -31,20 +30,20 @@ async def connect(connection_id: str, environ, auth): logger.error('No conversation_id in query params') raise ConnectionRefusedError('No conversation_id in query params') - github_token = '' + user_id = -1 if openhands_config.app_mode != AppMode.OSS: - user_id = '' - if auth and 'github_token' in auth: - github_token = auth['github_token'] - with Github(github_token) as g: - gh_user = await call_sync_from_async(g.get_user) - user_id = gh_user.id + cookies_str = environ.get('HTTP_COOKIE', '') + cookies = dict(cookie.split('=', 1) for cookie in cookies_str.split('; ')) + signed_token = cookies.get('github_auth', '') + if not signed_token: + logger.error('No github_auth cookie') + raise ConnectionRefusedError('No github_auth cookie') + decoded = jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256']) + user_id = decoded['github_user_id'] logger.info(f'User {user_id} is connecting to conversation {conversation_id}') - conversation_store = await ConversationStoreImpl.get_instance( - config, github_token - ) + conversation_store = await ConversationStoreImpl.get_instance(config, user_id) metadata = await conversation_store.get_metadata(conversation_id) if metadata.github_user_id != user_id: logger.error( @@ -54,7 +53,7 @@ async def connect(connection_id: str, environ, auth): f'User {user_id} is not allowed to join conversation {conversation_id}' ) - settings_store = await SettingsStoreImpl.get_instance(config, github_token) + settings_store = await SettingsStoreImpl.get_instance(config, user_id) settings = await settings_store.load() if not settings: diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index c613fa8d74225..a48c7286879a0 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -4,11 +4,11 @@ from fastapi import APIRouter, Body, Request from fastapi.responses import JSONResponse -from github import Github from pydantic import BaseModel from openhands.core.logger import openhands_logger as logger from openhands.events.stream import EventStreamSubscriber +from openhands.server.auth import get_user_id from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl from openhands.server.session.conversation_init_data import ConversationInitData from openhands.server.shared import config, session_manager @@ -21,7 +21,6 @@ from openhands.utils.async_utils import ( GENERAL_TIMEOUT, call_async_from_sync, - call_sync_from_async, wait_all, ) @@ -43,10 +42,9 @@ async def new_conversation(request: Request, data: InitSessionRequest): using the returned conversation ID """ logger.info('Initializing new conversation') - github_token = data.github_token or '' logger.info('Loading settings') - settings_store = await SettingsStoreImpl.get_instance(config, github_token) + settings_store = await SettingsStoreImpl.get_instance(config, get_user_id(request)) settings = await settings_store.load() logger.info('Settings loaded') @@ -54,11 +52,14 @@ async def new_conversation(request: Request, data: InitSessionRequest): if settings: session_init_args = {**settings.__dict__, **session_init_args} + github_token = getattr(request.state, 'github_token', '') session_init_args['github_token'] = github_token session_init_args['selected_repository'] = data.selected_repository conversation_init_data = ConversationInitData(**session_init_args) logger.info('Loading conversation store') - conversation_store = await ConversationStoreImpl.get_instance(config, github_token) + conversation_store = await ConversationStoreImpl.get_instance( + config, get_user_id(request) + ) logger.info('Conversation store loaded') conversation_id = uuid.uuid4().hex @@ -67,18 +68,11 @@ async def new_conversation(request: Request, data: InitSessionRequest): conversation_id = uuid.uuid4().hex logger.info(f'New conversation ID: {conversation_id}') - user_id = '' - if data.github_token: - logger.info('Fetching Github user ID') - with Github(data.github_token) as g: - gh_user = await call_sync_from_async(g.get_user) - user_id = gh_user.id - logger.info(f'Saving metadata for conversation {conversation_id}') await conversation_store.save_metadata( ConversationMetadata( conversation_id=conversation_id, - github_user_id=user_id, + github_user_id=get_user_id(request), selected_repository=data.selected_repository, ) ) @@ -90,9 +84,7 @@ async def new_conversation(request: Request, data: InitSessionRequest): try: event_stream.subscribe( EventStreamSubscriber.SERVER, - _create_conversation_update_callback( - data.github_token or '', conversation_id - ), + _create_conversation_update_callback(get_user_id(request), conversation_id), UPDATED_AT_CALLBACK_ID, ) except ValueError: @@ -107,8 +99,9 @@ async def search_conversations( page_id: str | None = None, limit: int = 20, ) -> ConversationInfoResultSet: - github_token = getattr(request.state, 'github_token', '') or '' - conversation_store = await ConversationStoreImpl.get_instance(config, github_token) + conversation_store = await ConversationStoreImpl.get_instance( + config, get_user_id(request) + ) conversation_metadata_result_set = await conversation_store.search(page_id, limit) conversation_ids = set( conversation.conversation_id @@ -134,8 +127,9 @@ async def search_conversations( async def get_conversation( conversation_id: str, request: Request ) -> ConversationInfo | None: - github_token = getattr(request.state, 'github_token', '') or '' - conversation_store = await ConversationStoreImpl.get_instance(config, github_token) + conversation_store = await ConversationStoreImpl.get_instance( + config, get_user_id(request) + ) try: metadata = await conversation_store.get_metadata(conversation_id) is_running = await session_manager.is_agent_loop_running(conversation_id) @@ -149,8 +143,9 @@ async def get_conversation( async def update_conversation( request: Request, conversation_id: str, title: str = Body(embed=True) ) -> bool: - github_token = getattr(request.state, 'github_token', '') or '' - conversation_store = await ConversationStoreImpl.get_instance(config, github_token) + conversation_store = await ConversationStoreImpl.get_instance( + config, get_user_id(request) + ) metadata = await conversation_store.get_metadata(conversation_id) if not metadata: return False @@ -164,8 +159,9 @@ async def delete_conversation( conversation_id: str, request: Request, ) -> bool: - github_token = getattr(request.state, 'github_token', '') or '' - conversation_store = await ConversationStoreImpl.get_instance(config, github_token) + conversation_store = await ConversationStoreImpl.get_instance( + config, get_user_id(request) + ) try: await conversation_store.get_metadata(conversation_id) except FileNotFoundError: @@ -205,21 +201,21 @@ async def _get_conversation_info( def _create_conversation_update_callback( - github_token: str, conversation_id: str + user_id: int, conversation_id: str ) -> Callable: def callback(*args, **kwargs): call_async_from_sync( _update_timestamp_for_conversation, GENERAL_TIMEOUT, - github_token, + user_id, conversation_id, ) return callback -async def _update_timestamp_for_conversation(github_token: str, conversation_id: str): - conversation_store = await ConversationStoreImpl.get_instance(config, github_token) +async def _update_timestamp_for_conversation(user_id: int, conversation_id: str): + conversation_store = await ConversationStoreImpl.get_instance(config, user_id) conversation = await conversation_store.get_metadata(conversation_id) conversation.last_updated_at = datetime.now() await conversation_store.save_metadata(conversation) diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index 4fcdb42f02ba6..1fa50aadb4863 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -2,6 +2,7 @@ from fastapi.responses import JSONResponse from openhands.core.logger import openhands_logger as logger +from openhands.server.auth import get_user_id from openhands.server.settings import Settings from openhands.server.shared import config, openhands_config from openhands.storage.conversation.conversation_store import ConversationStore @@ -19,9 +20,10 @@ @app.get('/settings') async def load_settings(request: Request) -> Settings | None: - github_token = getattr(request.state, 'github_token', '') or '' try: - settings_store = await SettingsStoreImpl.get_instance(config, github_token) + settings_store = await SettingsStoreImpl.get_instance( + config, get_user_id(request) + ) settings = await settings_store.load() if not settings: return JSONResponse( @@ -45,11 +47,10 @@ async def store_settings( request: Request, settings: Settings, ) -> JSONResponse: - github_token = '' - if hasattr(request.state, 'github_token'): - github_token = request.state.github_token try: - settings_store = await SettingsStoreImpl.get_instance(config, github_token) + settings_store = await SettingsStoreImpl.get_instance( + config, get_user_id(request) + ) existing_settings = await settings_store.load() if existing_settings: diff --git a/openhands/storage/conversation/conversation_store.py b/openhands/storage/conversation/conversation_store.py index 2a09322574bf5..1f0b41ea6c878 100644 --- a/openhands/storage/conversation/conversation_store.py +++ b/openhands/storage/conversation/conversation_store.py @@ -40,7 +40,5 @@ async def search( @classmethod @abstractmethod - async def get_instance( - cls, config: AppConfig, token: str | None - ) -> ConversationStore: + async def get_instance(cls, config: AppConfig, user_id: int) -> ConversationStore: """Get a store for the user represented by the token given""" diff --git a/openhands/storage/conversation/file_conversation_store.py b/openhands/storage/conversation/file_conversation_store.py index ee07aee9292c2..622b72f4bbcfa 100644 --- a/openhands/storage/conversation/file_conversation_store.py +++ b/openhands/storage/conversation/file_conversation_store.py @@ -90,7 +90,9 @@ def get_conversation_metadata_filename(self, conversation_id: str) -> str: return get_conversation_metadata_filename(conversation_id) @classmethod - async def get_instance(cls, config: AppConfig, token: str | None): + async def get_instance( + cls, config: AppConfig, user_id: int + ) -> FileConversationStore: file_store = get_file_store(config.file_store, config.file_store_path) return FileConversationStore(file_store) diff --git a/openhands/storage/data_models/conversation_metadata.py b/openhands/storage/data_models/conversation_metadata.py index 7761e077f976d..e75bbf21f8d53 100644 --- a/openhands/storage/data_models/conversation_metadata.py +++ b/openhands/storage/data_models/conversation_metadata.py @@ -5,7 +5,7 @@ @dataclass class ConversationMetadata: conversation_id: str - github_user_id: int | str + github_user_id: int selected_repository: str | None title: str | None = None last_updated_at: datetime | None = None diff --git a/openhands/storage/settings/file_settings_store.py b/openhands/storage/settings/file_settings_store.py index 413376759c8d4..c8703b304c116 100644 --- a/openhands/storage/settings/file_settings_store.py +++ b/openhands/storage/settings/file_settings_store.py @@ -30,6 +30,6 @@ async def store(self, settings: Settings): await call_sync_from_async(self.file_store.write, self.path, json_str) @classmethod - async def get_instance(cls, config: AppConfig, token: str | None): + async def get_instance(cls, config: AppConfig, user_id: int) -> FileSettingsStore: file_store = get_file_store(config.file_store, config.file_store_path) return FileSettingsStore(file_store) diff --git a/openhands/storage/settings/settings_store.py b/openhands/storage/settings/settings_store.py index 6e369b8f9739d..a371720600ac6 100644 --- a/openhands/storage/settings/settings_store.py +++ b/openhands/storage/settings/settings_store.py @@ -21,5 +21,5 @@ async def store(self, settings: Settings): @classmethod @abstractmethod - async def get_instance(cls, config: AppConfig, token: str | None) -> SettingsStore: + async def get_instance(cls, config: AppConfig, user_id: int) -> SettingsStore: """Get a store for the user represented by the token given""" diff --git a/tests/unit/test_conversation.py b/tests/unit/test_conversation.py index 938193d108f04..91731a601d6d6 100644 --- a/tests/unit/test_conversation.py +++ b/tests/unit/test_conversation.py @@ -28,7 +28,7 @@ def _patch_store(): 'title': 'Some Conversation', 'selected_repository': 'foobar', 'conversation_id': 'some_conversation_id', - 'github_user_id': 'github_user', + 'github_user_id': 12345, 'created_at': '2025-01-01T00:00:00', 'last_updated_at': '2025-01-01T00:01:00', } From cebd391b7a64712e000e85392822e3f58f76b058 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Mon, 6 Jan 2025 15:45:59 -0500 Subject: [PATCH 02/17] fix: better handle bashlex error (#6090) --- openhands/runtime/utils/bash.py | 15 ++++++++++----- tests/runtime/test_bash.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/openhands/runtime/utils/bash.py b/openhands/runtime/utils/bash.py index 84c5d02659573..70a24e2189f76 100644 --- a/openhands/runtime/utils/bash.py +++ b/openhands/runtime/utils/bash.py @@ -1,6 +1,7 @@ import os import re import time +import traceback import uuid from enum import Enum @@ -23,11 +24,11 @@ def split_bash_commands(commands): return [''] try: parsed = bashlex.parse(commands) - except bashlex.errors.ParsingError as e: + except (bashlex.errors.ParsingError, NotImplementedError): logger.debug( f'Failed to parse bash commands\n' f'[input]: {commands}\n' - f'[warning]: {e}\n' + f'[warning]: {traceback.format_exc()}\n' f'The original command will be returned as is.' ) # If parsing fails, return the original commands @@ -143,9 +144,13 @@ def visit_node(node): remaining = command[last_pos:] parts.append(remaining) return ''.join(parts) - except bashlex.errors.ParsingError: - # Fallback if parsing fails - logger.warning(f'Failed to parse command: {command}') + except (bashlex.errors.ParsingError, NotImplementedError): + logger.debug( + f'Failed to parse bash commands for special characters escape\n' + f'[input]: {command}\n' + f'[warning]: {traceback.format_exc()}\n' + f'The original command will be returned as is.' + ) return command diff --git a/tests/runtime/test_bash.py b/tests/runtime/test_bash.py index b97cdbc9266be..75f9085815fa8 100644 --- a/tests/runtime/test_bash.py +++ b/tests/runtime/test_bash.py @@ -153,6 +153,20 @@ def test_multiple_multiline_commands(temp_dir, runtime_cls, run_as_openhands): _close_test_runtime(runtime) +def test_complex_commands(temp_dir, runtime_cls): + cmd = """count=0; tries=0; while [ $count -lt 3 ]; do result=$(echo "Heads"); tries=$((tries+1)); echo "Flip $tries: $result"; if [ "$result" = "Heads" ]; then count=$((count+1)); else count=0; fi; done; echo "Got 3 heads in a row after $tries flips!";""" + + runtime = _load_runtime(temp_dir, runtime_cls) + try: + obs = _run_cmd_action(runtime, cmd) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0, 'The exit code should be 0.' + assert 'Got 3 heads in a row after 3 flips!' in obs.content + + finally: + _close_test_runtime(runtime) + + def test_no_ps2_in_output(temp_dir, runtime_cls, run_as_openhands): """Test that the PS2 sign is not added to the output of a multiline command.""" runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands) From 9515ac5e6231f88f907bfedfb8fcb567d9cc67f7 Mon Sep 17 00:00:00 2001 From: tofarr Date: Mon, 6 Jan 2025 14:26:48 -0700 Subject: [PATCH 03/17] Feat - browser client can now close sessions. (#6088) --- .../server/routes/manage_conversations.py | 2 +- openhands/server/session/manager.py | 22 ++++++++++++++++--- tests/unit/test_manager.py | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index a48c7286879a0..235b5801f24db 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -168,7 +168,7 @@ async def delete_conversation( return False is_running = await session_manager.is_agent_loop_running(conversation_id) if is_running: - return False + await session_manager.close_session(conversation_id) await conversation_store.delete_metadata(conversation_id) return True diff --git a/openhands/server/session/manager.py b/openhands/server/session/manager.py index 60b5bd2675af9..cc08d87466e17 100644 --- a/openhands/server/session/manager.py +++ b/openhands/server/session/manager.py @@ -156,6 +156,10 @@ async def _process_message(self, message: dict): flag = self._has_remote_connections_flags.get(sid) if flag: flag.set() + elif message_type == 'close_session': + sid = data['sid'] + if sid in self._local_agent_loops_by_sid: + await self._on_close_session(sid) elif message_type == 'session_closing': # Session closing event - We only get this in the event of graceful shutdown, # which can't be guaranteed - nodes can simply vanish unexpectedly! @@ -419,7 +423,7 @@ async def disconnect_from_session(self, connection_id: str): if should_continue(): asyncio.create_task(self._cleanup_session_later(sid)) else: - await self._close_session(sid) + await self._on_close_session(sid) async def _cleanup_session_later(self, sid: str): # Once there have been no connections to a session for a reasonable period, we close it @@ -451,10 +455,22 @@ async def _cleanup_session(self, sid: str) -> bool: json.dumps({'sid': sid, 'message_type': 'session_closing'}), ) - await self._close_session(sid) + await self._on_close_session(sid) return True - async def _close_session(self, sid: str): + async def close_session(self, sid: str): + session = self._local_agent_loops_by_sid.get(sid) + if session: + await self._on_close_session(sid) + + redis_client = self._get_redis_client() + if redis_client: + await redis_client.publish( + 'oh_event', + json.dumps({'sid': sid, 'message_type': 'close_session'}), + ) + + async def _on_close_session(self, sid: str): logger.info(f'_close_session:{sid}') # Clear up local variables diff --git a/tests/unit/test_manager.py b/tests/unit/test_manager.py index c2a61104a864e..144f79f9f4919 100644 --- a/tests/unit/test_manager.py +++ b/tests/unit/test_manager.py @@ -286,7 +286,7 @@ async def test_cleanup_session_connections(): } ) - await session_manager._close_session('session1') + await session_manager._on_close_session('session1') remaining_connections = session_manager.local_connection_id_to_session_id assert 'conn1' not in remaining_connections From 8cfcdd7ba3778d85e73913529bedace2286a7aa1 Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Mon, 6 Jan 2025 16:59:42 -0500 Subject: [PATCH 04/17] Add close method to EventStream (#6093) Co-authored-by: openhands Co-authored-by: tofarr --- openhands/events/stream.py | 83 ++++++++++++++++++++--- openhands/server/session/agent_session.py | 2 + openhands/server/session/conversation.py | 2 + openhands/server/session/manager.py | 1 + 4 files changed, 77 insertions(+), 11 deletions(-) diff --git a/openhands/events/stream.py b/openhands/events/stream.py index 63e4644106433..e58f90e79d9cd 100644 --- a/openhands/events/stream.py +++ b/openhands/events/stream.py @@ -1,9 +1,10 @@ import asyncio +import queue import threading from concurrent.futures import ThreadPoolExecutor from datetime import datetime from enum import Enum -from queue import Queue +from functools import partial from typing import Callable, Iterable from openhands.core.logger import openhands_logger as logger @@ -61,12 +62,19 @@ class EventStream: _subscribers: dict[str, dict[str, Callable]] _cur_id: int = 0 _lock: threading.Lock + _queue: queue.Queue[Event] + _queue_thread: threading.Thread + _queue_loop: asyncio.AbstractEventLoop | None + _thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]] - def __init__(self, sid: str, file_store: FileStore, num_workers: int = 1): + def __init__(self, sid: str, file_store: FileStore): self.sid = sid self.file_store = file_store - self._queue: Queue[Event] = Queue() + self._stop_flag = threading.Event() + self._queue: queue.Queue[Event] = queue.Queue() self._thread_pools: dict[str, dict[str, ThreadPoolExecutor]] = {} + self._thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]] = {} + self._queue_loop = None self._queue_thread = threading.Thread(target=self._run_queue_loop) self._queue_thread.daemon = True self._queue_thread.start() @@ -91,9 +99,54 @@ def __post_init__(self) -> None: if id >= self._cur_id: self._cur_id = id + 1 - def _init_thread_loop(self): + def _init_thread_loop(self, subscriber_id: str, callback_id: str): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) + if subscriber_id not in self._thread_loops: + self._thread_loops[subscriber_id] = {} + self._thread_loops[subscriber_id][callback_id] = loop + + def close(self): + self._stop_flag.set() + if self._queue_thread.is_alive(): + self._queue_thread.join() + + subscriber_ids = list(self._subscribers.keys()) + for subscriber_id in subscriber_ids: + callback_ids = list(self._subscribers[subscriber_id].keys()) + for callback_id in callback_ids: + self._clean_up_subscriber(subscriber_id, callback_id) + + def _clean_up_subscriber(self, subscriber_id: str, callback_id: str): + if subscriber_id not in self._subscribers: + logger.warning(f'Subscriber not found during cleanup: {subscriber_id}') + return + if callback_id not in self._subscribers[subscriber_id]: + logger.warning(f'Callback not found during cleanup: {callback_id}') + return + if ( + subscriber_id in self._thread_loops + and callback_id in self._thread_loops[subscriber_id] + ): + loop = self._thread_loops[subscriber_id][callback_id] + try: + loop.stop() + loop.close() + except Exception as e: + logger.warning( + f'Error closing loop for {subscriber_id}/{callback_id}: {e}' + ) + del self._thread_loops[subscriber_id][callback_id] + + if ( + subscriber_id in self._thread_pools + and callback_id in self._thread_pools[subscriber_id] + ): + pool = self._thread_pools[subscriber_id][callback_id] + pool.shutdown() + del self._thread_pools[subscriber_id][callback_id] + + del self._subscribers[subscriber_id][callback_id] def _get_filename_for_id(self, id: int) -> str: return get_conversation_event_filename(self.sid, id) @@ -176,7 +229,8 @@ def get_latest_event_id(self) -> int: def subscribe( self, subscriber_id: EventStreamSubscriber, callback: Callable, callback_id: str ): - pool = ThreadPoolExecutor(max_workers=1, initializer=self._init_thread_loop) + initializer = partial(self._init_thread_loop, subscriber_id, callback_id) + pool = ThreadPoolExecutor(max_workers=1, initializer=initializer) if subscriber_id not in self._subscribers: self._subscribers[subscriber_id] = {} self._thread_pools[subscriber_id] = {} @@ -198,7 +252,7 @@ def unsubscribe(self, subscriber_id: EventStreamSubscriber, callback_id: str): logger.warning(f'Callback not found during unsubscribe: {callback_id}') return - del self._subscribers[subscriber_id][callback_id] + self._clean_up_subscriber(subscriber_id, callback_id) def add_event(self, event: Event, source: EventSource): if hasattr(event, '_id') and event.id is not None: @@ -217,13 +271,20 @@ def add_event(self, event: Event, source: EventSource): self._queue.put(event) def _run_queue_loop(self): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete(self._process_queue()) + self._queue_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._queue_loop) + try: + self._queue_loop.run_until_complete(self._process_queue()) + finally: + self._queue_loop.close() async def _process_queue(self): - while should_continue(): - event = self._queue.get() + while should_continue() and not self._stop_flag.is_set(): + event = None + try: + event = self._queue.get(timeout=0.1) + except queue.Empty: + continue for key in sorted(self._subscribers.keys()): callbacks = self._subscribers[key] for callback_id in callbacks: diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index 91e3921596a5e..9f761a9298beb 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -131,6 +131,8 @@ async def _close(self): f'Waited too long for initialization to finish before closing session {self.sid}' ) break + if self.event_stream is not None: + self.event_stream.close() if self.controller is not None: end_state = self.controller.get_state() end_state.save_to_session(self.sid, self.file_store) diff --git a/openhands/server/session/conversation.py b/openhands/server/session/conversation.py index 11fdb22d8632f..14aa1363e63ba 100644 --- a/openhands/server/session/conversation.py +++ b/openhands/server/session/conversation.py @@ -43,4 +43,6 @@ async def connect(self): await self.runtime.connect() async def disconnect(self): + if self.event_stream: + self.event_stream.close() asyncio.create_task(call_sync_from_async(self.runtime.close)) diff --git a/openhands/server/session/manager.py b/openhands/server/session/manager.py index cc08d87466e17..c7577cc1b5583 100644 --- a/openhands/server/session/manager.py +++ b/openhands/server/session/manager.py @@ -201,6 +201,7 @@ async def attach_to_conversation(self, sid: str) -> Conversation | None: await c.connect() except AgentRuntimeUnavailableError as e: logger.error(f'Error connecting to conversation {c.sid}: {e}') + await c.disconnect() return None end_time = time.time() logger.info( From 1f8a0180d3050bcbc5ade0682dc39ba4ce531fe8 Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Tue, 7 Jan 2025 07:22:58 +0900 Subject: [PATCH 05/17] Add runtime size configuration feature (#5805) Co-authored-by: openhands Co-authored-by: amanape <83104063+amanape@users.noreply.github.com> --- .../settings/runtime-size-selector.test.tsx | 35 ++++++++ .../modals/settings/settings-form.test.tsx | 45 ++++++++++ .../shared/inputs/advanced-option-switch.tsx | 2 +- .../modals/settings/runtime-size-selector.tsx | 52 ++++++++++++ .../shared/modals/settings/settings-form.tsx | 29 +++++-- frontend/src/hooks/query/use-settings.ts | 2 + .../src/hooks/use-maybe-migrate-settings.ts | 2 +- frontend/src/i18n/translation.json | 14 +++ frontend/src/services/settings.ts | 10 +++ openhands/server/routes/settings.py | 7 ++ openhands/server/settings.py | 1 + tests/unit/test_settings_api.py | 85 +++++++++++++++++++ 12 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 frontend/__tests__/components/shared/modals/settings/runtime-size-selector.test.tsx create mode 100644 frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx create mode 100644 frontend/src/components/shared/modals/settings/runtime-size-selector.tsx create mode 100644 tests/unit/test_settings_api.py diff --git a/frontend/__tests__/components/shared/modals/settings/runtime-size-selector.test.tsx b/frontend/__tests__/components/shared/modals/settings/runtime-size-selector.test.tsx new file mode 100644 index 0000000000000..e607c6f026f41 --- /dev/null +++ b/frontend/__tests__/components/shared/modals/settings/runtime-size-selector.test.tsx @@ -0,0 +1,35 @@ +import { screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { renderWithProviders } from "test-utils"; +import { RuntimeSizeSelector } from "#/components/shared/modals/settings/runtime-size-selector"; + +const renderRuntimeSizeSelector = () => + renderWithProviders(); + +describe("RuntimeSizeSelector", () => { + it("should show both runtime size options", () => { + renderRuntimeSizeSelector(); + // The options are in the hidden select element + const select = screen.getByRole("combobox", { hidden: true }); + expect(select).toHaveValue("1"); + expect(select).toHaveDisplayValue("1x (2 core, 8G)"); + expect(select.children).toHaveLength(3); // Empty option + 2 size options + }); + + it("should show the full description text for disabled options", async () => { + renderRuntimeSizeSelector(); + + // Click the button to open the dropdown + const button = screen.getByRole("button", { + name: "1x (2 core, 8G) SETTINGS_FORM$RUNTIME_SIZE_LABEL", + }); + button.click(); + + // Wait for the dropdown to open and find the description text + const description = await screen.findByText( + "Runtime sizes over 1 are disabled by default, please contact contact@all-hands.dev to get access to larger runtimes.", + ); + expect(description).toBeInTheDocument(); + expect(description).toHaveClass("whitespace-normal", "break-words"); + }); +}); diff --git a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx new file mode 100644 index 0000000000000..e373fdfb3e4ff --- /dev/null +++ b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx @@ -0,0 +1,45 @@ +import { screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { renderWithProviders } from "test-utils"; +import { createRoutesStub } from "react-router"; +import { DEFAULT_SETTINGS } from "#/services/settings"; +import { SettingsForm } from "#/components/shared/modals/settings/settings-form"; +import OpenHands from "#/api/open-hands"; + +describe("SettingsForm", () => { + const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "123", + }); + + const RouterStub = createRoutesStub([ + { + Component: () => ( + {}} + /> + ), + path: "/", + }, + ]); + + it("should not show runtime size selector by default", () => { + renderWithProviders(); + expect(screen.queryByText("Runtime Size")).not.toBeInTheDocument(); + }); + + it("should show runtime size selector when advanced options are enabled", async () => { + renderWithProviders(); + const advancedSwitch = screen.getByRole("switch", { + name: "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL", + }); + fireEvent.click(advancedSwitch); + await screen.findByText("SETTINGS_FORM$RUNTIME_SIZE_LABEL"); + }); +}); diff --git a/frontend/src/components/shared/inputs/advanced-option-switch.tsx b/frontend/src/components/shared/inputs/advanced-option-switch.tsx index 9b5368b4ab654..50709f9de3acc 100644 --- a/frontend/src/components/shared/inputs/advanced-option-switch.tsx +++ b/frontend/src/components/shared/inputs/advanced-option-switch.tsx @@ -20,7 +20,7 @@ export function AdvancedOptionSwitch({ + + + + ); +} diff --git a/frontend/src/components/shared/modals/settings/settings-form.tsx b/frontend/src/components/shared/modals/settings/settings-form.tsx index 7aba4856e2279..8390c237a66fb 100644 --- a/frontend/src/components/shared/modals/settings/settings-form.tsx +++ b/frontend/src/components/shared/modals/settings/settings-form.tsx @@ -21,6 +21,9 @@ import { ModalBackdrop } from "../modal-backdrop"; import { ModelSelector } from "./model-selector"; import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; +import { RuntimeSizeSelector } from "./runtime-size-selector"; +import { useConfig } from "#/hooks/query/use-config"; + interface SettingsFormProps { disabled?: boolean; settings: Settings; @@ -40,6 +43,7 @@ export function SettingsForm({ }: SettingsFormProps) { const { mutateAsync: saveSettings } = useSaveSettings(); const endSession = useEndSession(); + const { data: config } = useConfig(); const location = useLocation(); const { t } = useTranslation(); @@ -97,6 +101,8 @@ export function SettingsForm({ posthog.capture("settings_saved", { LLM_MODEL: newSettings.LLM_MODEL, LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET", + REMOTE_RUNTIME_RESOURCE_FACTOR: + newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR, }); }; @@ -122,6 +128,8 @@ export function SettingsForm({ } }; + const isSaasMode = config?.APP_MODE === "saas"; + return (
- {showAdvancedOptions && ( - - )} - {showAdvancedOptions && ( <> + + + {isSaasMode && ( + + )} + { CONFIRMATION_MODE: apiSettings.confirmation_mode, SECURITY_ANALYZER: apiSettings.security_analyzer, LLM_API_KEY: apiSettings.llm_api_key, + REMOTE_RUNTIME_RESOURCE_FACTOR: + apiSettings.remote_runtime_resource_factor, }; } diff --git a/frontend/src/hooks/use-maybe-migrate-settings.ts b/frontend/src/hooks/use-maybe-migrate-settings.ts index 26892c9745d75..4f5bbd4712a0d 100644 --- a/frontend/src/hooks/use-maybe-migrate-settings.ts +++ b/frontend/src/hooks/use-maybe-migrate-settings.ts @@ -3,8 +3,8 @@ import React from "react"; import { useSettingsUpToDate } from "#/context/settings-up-to-date-context"; import { - DEFAULT_SETTINGS, getCurrentSettingsVersion, + DEFAULT_SETTINGS, getLocalStorageSettings, } from "#/services/settings"; import { useSaveSettings } from "./mutation/use-save-settings"; diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 3707ba804b5f4..167909c995a20 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -426,6 +426,20 @@ "fr": "Réinitialiser aux valeurs par défaut", "tr": "Varsayılanlara Sıfırla" }, + "SETTINGS_FORM$RUNTIME_SIZE_LABEL": { + "en": "Runtime Settings", + "zh-CN": "运行时设置", + "de": "Laufzeiteinstellungen", + "ko-KR": "런타임 설정", + "no": "Kjøretidsinnstillinger", + "zh-TW": "運行時設定", + "it": "Impostazioni Runtime", + "pt": "Configurações de Runtime", + "es": "Configuración de Runtime", + "ar": "إعدادات وقت التشغيل", + "fr": "Paramètres d'exécution", + "tr": "Çalışma Zamanı Ayarları" + }, "CONFIGURATION$SETTINGS_NEED_UPDATE_MESSAGE": { "en": "We've changed some settings in the latest update. Take a minute to review.", "de": "Mit dem letzten Update haben wir ein paar Einstellungen geändert. Bitte kontrollieren Ihre Einstellungen.", diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts index b42d7f1042fc9..bf2cfa8e97542 100644 --- a/frontend/src/services/settings.ts +++ b/frontend/src/services/settings.ts @@ -8,6 +8,7 @@ export type Settings = { LLM_API_KEY: string | null; CONFIRMATION_MODE: boolean; SECURITY_ANALYZER: string; + REMOTE_RUNTIME_RESOURCE_FACTOR: number; }; export type ApiSettings = { @@ -18,6 +19,7 @@ export type ApiSettings = { llm_api_key: string | null; confirmation_mode: boolean; security_analyzer: string; + remote_runtime_resource_factor: number; }; export const DEFAULT_SETTINGS: Settings = { @@ -28,6 +30,7 @@ export const DEFAULT_SETTINGS: Settings = { LLM_API_KEY: null, CONFIRMATION_MODE: false, SECURITY_ANALYZER: "", + REMOTE_RUNTIME_RESOURCE_FACTOR: 1, }; export const getCurrentSettingsVersion = () => { @@ -66,6 +69,8 @@ export const getLocalStorageSettings = (): Settings => { LLM_API_KEY: llmApiKey || DEFAULT_SETTINGS.LLM_API_KEY, CONFIRMATION_MODE: confirmationMode || DEFAULT_SETTINGS.CONFIRMATION_MODE, SECURITY_ANALYZER: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER, + REMOTE_RUNTIME_RESOURCE_FACTOR: + DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR, }; }; @@ -73,3 +78,8 @@ export const getLocalStorageSettings = (): Settings => { * Get the default settings */ export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS; + +/** + * Get the current settings, either from local storage or defaults + */ +export const getSettings = (): Settings => getLocalStorageSettings(); diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index 1fa50aadb4863..ca45c142ff1c7 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -57,6 +57,13 @@ async def store_settings( # LLM key isn't on the frontend, so we need to keep it if unset if settings.llm_api_key is None: settings.llm_api_key = existing_settings.llm_api_key + + # Update sandbox config with new settings + if settings.remote_runtime_resource_factor is not None: + config.sandbox.remote_runtime_resource_factor = ( + settings.remote_runtime_resource_factor + ) + await settings_store.store(settings) return JSONResponse( diff --git a/openhands/server/settings.py b/openhands/server/settings.py index e78694c6ca329..57c879e49d45a 100644 --- a/openhands/server/settings.py +++ b/openhands/server/settings.py @@ -15,3 +15,4 @@ class Settings: llm_model: str | None = None llm_api_key: str | None = None llm_base_url: str | None = None + remote_runtime_resource_factor: int | None = None diff --git a/tests/unit/test_settings_api.py b/tests/unit/test_settings_api.py new file mode 100644 index 0000000000000..a8e52a2390104 --- /dev/null +++ b/tests/unit/test_settings_api.py @@ -0,0 +1,85 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from openhands.core.config.sandbox_config import SandboxConfig +from openhands.server.app import app +from openhands.server.settings import Settings + + +@pytest.fixture +def test_client(): + # Mock the middleware that adds github_token + class MockMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope['type'] == 'http': + scope['state'] = {'github_token': 'test-token'} + await self.app(scope, receive, send) + + # Replace the middleware + app.middleware_stack = None # Clear existing middleware + app.add_middleware(MockMiddleware) + + return TestClient(app) + + +@pytest.fixture +def mock_settings_store(): + with patch('openhands.server.routes.settings.SettingsStoreImpl') as mock: + store_instance = MagicMock() + mock.get_instance = AsyncMock(return_value=store_instance) + store_instance.load = AsyncMock() + store_instance.store = AsyncMock() + yield store_instance + + +@pytest.mark.asyncio +async def test_settings_api_runtime_factor(test_client, mock_settings_store): + # Mock the settings store to return None initially (no existing settings) + mock_settings_store.load.return_value = None + + # Test data with remote_runtime_resource_factor + settings_data = { + 'language': 'en', + 'agent': 'test-agent', + 'max_iterations': 100, + 'security_analyzer': 'default', + 'confirmation_mode': True, + 'llm_model': 'test-model', + 'llm_api_key': None, + 'llm_base_url': 'https://test.com', + 'remote_runtime_resource_factor': 2, + } + + # The test_client fixture already handles authentication + + # Make the POST request to store settings + response = test_client.post('/api/settings', json=settings_data) + assert response.status_code == 200 + + # Verify the settings were stored with the correct runtime factor + stored_settings = mock_settings_store.store.call_args[0][0] + assert stored_settings.remote_runtime_resource_factor == 2 + + # Mock settings store to return our settings for the GET request + mock_settings_store.load.return_value = Settings(**settings_data) + + # Make a GET request to retrieve settings + response = test_client.get('/api/settings') + assert response.status_code == 200 + assert response.json()['remote_runtime_resource_factor'] == 2 + + # Verify that the sandbox config gets updated when settings are loaded + with patch('openhands.server.shared.config') as mock_config: + mock_config.sandbox = SandboxConfig() + response = test_client.get('/api/settings') + assert response.status_code == 200 + + # Verify that the sandbox config was updated with the new value + mock_settings_store.store.assert_called() + stored_settings = mock_settings_store.store.call_args[0][0] + assert stored_settings.remote_runtime_resource_factor == 2 From fb53ae43c028416590f307bef233b02d18fedc7e Mon Sep 17 00:00:00 2001 From: Boxuan Li Date: Mon, 6 Jan 2025 14:36:59 -0800 Subject: [PATCH 06/17] Add a stress test for eventstream runtime (#6038) Co-authored-by: Xingyao Wang --- tests/runtime/test_stress_docker_runtime.py | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/runtime/test_stress_docker_runtime.py diff --git a/tests/runtime/test_stress_docker_runtime.py b/tests/runtime/test_stress_docker_runtime.py new file mode 100644 index 0000000000000..e7d196d482071 --- /dev/null +++ b/tests/runtime/test_stress_docker_runtime.py @@ -0,0 +1,35 @@ +"""Stress tests for the DockerRuntime, which connects to the ActionExecutor running in the sandbox.""" + +import pytest +from conftest import TEST_IN_CI, _close_test_runtime, _load_runtime + +from openhands.core.logger import openhands_logger as logger +from openhands.events.action import CmdRunAction + + +@pytest.mark.skipif( + TEST_IN_CI, + reason='This test should only be run locally, not in CI.', +) +def test_stress_docker_runtime(temp_dir, runtime_cls, repeat=1): + runtime = _load_runtime(temp_dir, runtime_cls) + + action = CmdRunAction( + command='sudo apt-get update && sudo apt-get install -y stress-ng' + ) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + for _ in range(repeat): + # run stress-ng stress tests for 5 minutes + # FIXME: this would make Docker daemon die, even though running this + # command on its own in the same container is fine + action = CmdRunAction(command='stress-ng --all 1 -t 5m') + action.timeout = 600 + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + + _close_test_runtime(runtime) From 23425c85aa25b437ea9a3de4a9521e82256eeae8 Mon Sep 17 00:00:00 2001 From: OpenHands Date: Tue, 7 Jan 2025 14:49:59 +0900 Subject: [PATCH 07/17] Fix issue #6063: [Bug]: Build error on `opencv-python` (#6064) --- poetry.lock | 20 +------------------- pyproject.toml | 2 -- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/poetry.lock b/poetry.lock index bb3729628113a..32b1649cb0337 100644 --- a/poetry.lock +++ b/poetry.lock @@ -5422,24 +5422,6 @@ typing-extensions = ">=4.11,<5" datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] realtime = ["websockets (>=13,<15)"] -[[package]] -name = "opencv-python" -version = "4.10.0.84" -description = "Wrapper package for OpenCV python bindings." -optional = false -python-versions = ">=3.6" -files = [ - {file = "opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526"}, - {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98"}, - {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6"}, - {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f"}, - {file = "opencv_python-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:2db02bb7e50b703f0a2d50c50ced72e95c574e1e5a0bb35a8a86d0b35c98c236"}, - {file = "opencv_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:32dbbd94c26f611dc5cc6979e6b7aa1f55a64d6b463cc1dcd3c95505a63e48fe"}, -] - -[package.dependencies] -numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""} - [[package]] name = "openhands-aci" version = "0.1.6" @@ -10083,4 +10065,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "6f8fd9ffcc411aed1c8f50aff98e36bf06932c27b82485e4f9fd05bbe7b195c4" +content-hash = "3c4cae19fcbd9183bde1bd88cea55454921281e26447d9a2c64404a5defffb3e" diff --git a/pyproject.toml b/pyproject.toml index db70ae05e01ba..d94d979253f0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,6 @@ pytest-forked = "*" pytest-xdist = "*" flake8 = "*" openai = "*" -opencv-python = "*" pandas = "*" reportlab = "*" @@ -108,7 +107,6 @@ jupyterlab = "*" notebook = "*" jupyter_kernel_gateway = "*" flake8 = "*" -opencv-python = "*" [build-system] build-backend = "poetry.core.masonry.api" From aad7a612c1cfbc91fef665d430898fc1ebf88fa2 Mon Sep 17 00:00:00 2001 From: Mark Watson Date: Tue, 7 Jan 2025 06:48:06 -0800 Subject: [PATCH 08/17] fix(frontend): prevent repository name overflow in project menu card (#6091) Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com> --- .../project-menu/project-menu-details.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/features/project-menu/project-menu-details.tsx b/frontend/src/components/features/project-menu/project-menu-details.tsx index 8bb67a2ec8ba7..3766d00e30c03 100644 --- a/frontend/src/components/features/project-menu/project-menu-details.tsx +++ b/frontend/src/components/features/project-menu/project-menu-details.tsx @@ -16,16 +16,24 @@ export function ProjectMenuDetails({ }: ProjectMenuDetailsProps) { const { t } = useTranslation(); return ( -
+
- {avatar && } - {repoName} - + {avatar && ( + + )} + + {repoName} + + Date: Tue, 7 Jan 2025 08:25:45 -0700 Subject: [PATCH 09/17] Fix for delete conversation (#6097) --- .../conversation/file_conversation_store.py | 5 ++++- tests/unit/test_conversation.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/openhands/storage/conversation/file_conversation_store.py b/openhands/storage/conversation/file_conversation_store.py index 622b72f4bbcfa..15679f404903f 100644 --- a/openhands/storage/conversation/file_conversation_store.py +++ b/openhands/storage/conversation/file_conversation_store.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path from pydantic import TypeAdapter @@ -39,7 +40,9 @@ async def get_metadata(self, conversation_id: str) -> ConversationMetadata: return result async def delete_metadata(self, conversation_id: str) -> None: - path = self.get_conversation_metadata_filename(conversation_id) + path = str( + Path(self.get_conversation_metadata_filename(conversation_id)).parent + ) await call_sync_from_async(self.file_store.delete, path) async def exists(self, conversation_id: str) -> bool: diff --git a/tests/unit/test_conversation.py b/tests/unit/test_conversation.py index 91731a601d6d6..7610cf2525cb4 100644 --- a/tests/unit/test_conversation.py +++ b/tests/unit/test_conversation.py @@ -6,6 +6,7 @@ import pytest from openhands.server.routes.manage_conversations import ( + delete_conversation, get_conversation, search_conversations, update_conversation, @@ -114,3 +115,16 @@ async def test_update_conversation(): selected_repository='foobar', ) assert conversation == expected + + +@pytest.mark.asyncio +async def test_delete_conversation(): + with _patch_store(): + await delete_conversation( + 'some_conversation_id', + MagicMock(state=MagicMock(github_token='')), + ) + conversation = await get_conversation( + 'some_conversation_id', MagicMock(state=MagicMock(github_token='')) + ) + assert conversation is None From 5469d5311df01b0e366ae8e1b6dbbffc1aedbd89 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jan 2025 16:28:08 +0100 Subject: [PATCH 10/17] chore(deps): bump the version-all group across 1 directory with 11 updates (#6110) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 135 +++++++++++++++++++++++++------------------------ pyproject.toml | 6 +-- 2 files changed, 71 insertions(+), 70 deletions(-) diff --git a/poetry.lock b/poetry.lock index 32b1649cb0337..e7cd99236ee38 100644 --- a/poetry.lock +++ b/poetry.lock @@ -195,22 +195,23 @@ vertex = ["google-auth (>=2,<3)"] [[package]] name = "anyio" -version = "4.6.2.post1" +version = "4.8.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" files = [ - {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, - {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, + {file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"}, + {file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"}, ] [package.dependencies] idna = ">=2.8" sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] trio = ["trio (>=0.26.1)"] [[package]] @@ -553,17 +554,17 @@ files = [ [[package]] name = "boto3" -version = "1.35.90" +version = "1.35.93" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.35.90-py3-none-any.whl", hash = "sha256:b0874233057995a8f0c813f5b45a36c09630e74c43d7a7c64db2feef2915d493"}, - {file = "boto3-1.35.90.tar.gz", hash = "sha256:dc56caaaab2157a4bfc109c88b50cd032f3ac66c06d17f8ee335b798eaf53e5c"}, + {file = "boto3-1.35.93-py3-none-any.whl", hash = "sha256:7de2c44c960e486f3c57e5203ea6393c6c4f0914c5f81c789ceb8b5d2ba5d1c5"}, + {file = "boto3-1.35.93.tar.gz", hash = "sha256:2446e819cf4e295833474cdcf2c92bc82718ce537e9ee1f17f7e3d237f60e69b"}, ] [package.dependencies] -botocore = ">=1.35.90,<1.36.0" +botocore = ">=1.35.93,<1.36.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -572,13 +573,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.35.90" +version = "1.35.93" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.35.90-py3-none-any.whl", hash = "sha256:51dcbe1b32e2ac43dac17091f401a00ce5939f76afe999081802009cce1e92e4"}, - {file = "botocore-1.35.90.tar.gz", hash = "sha256:f007f58e8e3c1ad0412a6ddfae40ed92a7bca571c068cb959902bcf107f2ae48"}, + {file = "botocore-1.35.93-py3-none-any.whl", hash = "sha256:47f7161000af6036f806449e3de12acdd3ec11aac7f5578e43e96241413a0f8f"}, + {file = "botocore-1.35.93.tar.gz", hash = "sha256:b8d245a01e7d64c41edcf75a42be158df57b9518a83a3dbf5c7e4b8c2bc540cc"}, ] [package.dependencies] @@ -941,13 +942,13 @@ numpy = "*" [[package]] name = "chromadb" -version = "0.6.1" +version = "0.6.2" description = "Chroma." optional = false python-versions = ">=3.9" files = [ - {file = "chromadb-0.6.1-py3-none-any.whl", hash = "sha256:3483ea9e1271b647f3696e1f39ee9e464bb23a6a9913f42c57a84657c34467bb"}, - {file = "chromadb-0.6.1.tar.gz", hash = "sha256:af55d143fd887f344ff05cd40560566dda1dd13e90ec5a13fb0f5278eb8cde75"}, + {file = "chromadb-0.6.2-py3-none-any.whl", hash = "sha256:77a5e07097e36cdd49d8d2925d0c4d28291cabc9677787423d2cc7c426e8895b"}, + {file = "chromadb-0.6.2.tar.gz", hash = "sha256:e9e11f04d3850796711ee05dad4e918c75ec7b62ab9cbe7b4588b68a26aaea06"}, ] [package.dependencies] @@ -2175,13 +2176,13 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] [[package]] name = "google-api-python-client" -version = "2.156.0" +version = "2.157.0" description = "Google API Client Library for Python" optional = false python-versions = ">=3.7" files = [ - {file = "google_api_python_client-2.156.0-py2.py3-none-any.whl", hash = "sha256:6352185c505e1f311f11b0b96c1b636dcb0fec82cd04b80ac5a671ac4dcab339"}, - {file = "google_api_python_client-2.156.0.tar.gz", hash = "sha256:b809c111ded61716a9c1c7936e6899053f13bae3defcdfda904bd2ca68065b9c"}, + {file = "google_api_python_client-2.157.0-py2.py3-none-any.whl", hash = "sha256:0b0231db106324c659bf8b85f390391c00da57a60ebc4271e33def7aac198c75"}, + {file = "google_api_python_client-2.157.0.tar.gz", hash = "sha256:2ee342d0967ad1cedec43ccd7699671d94bff151e1f06833ea81303f9a6d86fd"}, ] [package.dependencies] @@ -3713,13 +3714,13 @@ adal = ["adal (>=1.0.2)"] [[package]] name = "libtmux" -version = "0.37.0" +version = "0.39.0" description = "Typed library that provides an ORM wrapper for tmux, a terminal multiplexer." optional = false -python-versions = "<4.0,>=3.8" +python-versions = "<4.0,>=3.9" files = [ - {file = "libtmux-0.37.0-py3-none-any.whl", hash = "sha256:7e8cbab30b033d132b6fca5dddb575bb7f6a1fd802328e7174f9b49023556376"}, - {file = "libtmux-0.37.0.tar.gz", hash = "sha256:21955c5dce6332db41abad5e26ae8c4062ef2b9a89099bd57a36f52be1d5270f"}, + {file = "libtmux-0.39.0-py3-none-any.whl", hash = "sha256:6b6e338be2727f67aa6b7eb67fa134368fa3c3eac5df27565396467692891c1e"}, + {file = "libtmux-0.39.0.tar.gz", hash = "sha256:59346aeef3c0d6017f3bc5e23248d43cdf50f32b775b9cb5d9ff5e2e5f3059f4"}, ] [[package]] @@ -3750,13 +3751,13 @@ types-tqdm = "*" [[package]] name = "litellm" -version = "1.56.6" +version = "1.57.1" description = "Library to easily interface with LLM API providers" optional = false python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" files = [ - {file = "litellm-1.56.6-py3-none-any.whl", hash = "sha256:dc04becae6b09b401edfc13e9e648443e425a52c1d7217351c7841811dc8dbec"}, - {file = "litellm-1.56.6.tar.gz", hash = "sha256:24612fff40f31044257c16bc29aa086cbb084b830e427a19f4adb96deeea626d"}, + {file = "litellm-1.57.1-py3-none-any.whl", hash = "sha256:f9e93689f2d96df3bcebe723d44b6e2e71b9b047ec7ebd1054b6c9bc96cd9515"}, + {file = "litellm-1.57.1.tar.gz", hash = "sha256:2ce6ce1707c92fb278f828a8ea058fa12b3eeb8081dd8c10776569995e03bb6f"}, ] [package.dependencies] @@ -3793,19 +3794,19 @@ pydantic = ">=1.10" [[package]] name = "llama-index" -version = "0.12.9" +version = "0.12.10" description = "Interface between LLMs and your data" optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "llama_index-0.12.9-py3-none-any.whl", hash = "sha256:95c39d8055c7d19bd5f099560b53c0971ae9997ebe46f7438766189ed48e4456"}, - {file = "llama_index-0.12.9.tar.gz", hash = "sha256:2f8d671e6ca7e5b33b0f5cbddef8c0a11eb1e39781f1be65e9bd0c4a7a0deb5b"}, + {file = "llama_index-0.12.10-py3-none-any.whl", hash = "sha256:c397e1355d48a043a4636857519185f9a47eb25e6482134c28e75f64cd4fe11e"}, + {file = "llama_index-0.12.10.tar.gz", hash = "sha256:942bd89f6363a553ff30f053df3c12703ac81c726d1afb7fc14555b0ede5e8a2"}, ] [package.dependencies] llama-index-agent-openai = ">=0.4.0,<0.5.0" llama-index-cli = ">=0.4.0,<0.5.0" -llama-index-core = ">=0.12.9,<0.13.0" +llama-index-core = ">=0.12.10,<0.13.0" llama-index-embeddings-openai = ">=0.3.0,<0.4.0" llama-index-indices-managed-llama-cloud = ">=0.4.0" llama-index-llms-openai = ">=0.3.0,<0.4.0" @@ -3850,13 +3851,13 @@ llama-index-llms-openai = ">=0.3.0,<0.4.0" [[package]] name = "llama-index-core" -version = "0.12.9" +version = "0.12.10.post1" description = "Interface between LLMs and your data" optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "llama_index_core-0.12.9-py3-none-any.whl", hash = "sha256:75bfdece8e1eb37faba43345cfbd9a8004859c177c1b5b358fc77620908c0f3f"}, - {file = "llama_index_core-0.12.9.tar.gz", hash = "sha256:a6a702af13f8a840ff2a459024d21280e5b04d37f22c73efdc52def60e047af6"}, + {file = "llama_index_core-0.12.10.post1-py3-none-any.whl", hash = "sha256:897e8cd4efeff6842580b043bdf4008ac60f693df1de2bfd975307a4845707c2"}, + {file = "llama_index_core-0.12.10.post1.tar.gz", hash = "sha256:af27bea4d1494ba84983a649976e60e3de677a73946aa45ed12ce27e3a623ddf"}, ] [package.dependencies] @@ -4483,13 +4484,13 @@ files = [ [[package]] name = "minio" -version = "7.2.13" +version = "7.2.14" description = "MinIO Python SDK for Amazon S3 Compatible Cloud Storage" optional = false python-versions = ">=3.9" files = [ - {file = "minio-7.2.13-py3-none-any.whl", hash = "sha256:ad806be056e6b49510ad27f0782976c0b9d4c16baccd9d75518d97709bd5c105"}, - {file = "minio-7.2.13.tar.gz", hash = "sha256:0fc878da4c5139138f66d3f00ae898ed74cead854b900420b02cd68cf4be7133"}, + {file = "minio-7.2.14-py3-none-any.whl", hash = "sha256:868dfe907e1702ce4bec86df1f3ced577a73ca85f344ef898d94fe2b5237f8c1"}, + {file = "minio-7.2.14.tar.gz", hash = "sha256:f5c24bf236fefd2edc567cd4455dc49a11ad8ff7ac984bb031b849d82f01222a"}, ] [package.dependencies] @@ -4625,12 +4626,12 @@ type = ["mypy (==1.11.2)"] [[package]] name = "modal" -version = "0.70.3" +version = "0.71.3" description = "Python client library for Modal" optional = false python-versions = ">=3.9" files = [ - {file = "modal-0.70.3-py3-none-any.whl", hash = "sha256:9a39f59358d07d8b884e244814eacbad5d66fcbe2b42e7a61e8759226825dcd4"}, + {file = "modal-0.71.3-py3-none-any.whl", hash = "sha256:38d1256cbc0329867841d7ba914f793a95454bef74daf4de4631cb5af59b5df4"}, ] [package.dependencies] @@ -5399,13 +5400,13 @@ sympy = "*" [[package]] name = "openai" -version = "1.58.1" +version = "1.59.3" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" files = [ - {file = "openai-1.58.1-py3-none-any.whl", hash = "sha256:e2910b1170a6b7f88ef491ac3a42c387f08bd3db533411f7ee391d166571d63c"}, - {file = "openai-1.58.1.tar.gz", hash = "sha256:f5a035fd01e141fc743f4b0e02c41ca49be8fab0866d3b67f5f29b4f4d3c0973"}, + {file = "openai-1.59.3-py3-none-any.whl", hash = "sha256:b041887a0d8f3e70d1fc6ffbb2bf7661c3b9a2f3e806c04bf42f572b9ac7bc37"}, + {file = "openai-1.59.3.tar.gz", hash = "sha256:7f7fff9d8729968588edf1524e73266e8593bb6cab09298340efb755755bb66f"}, ] [package.dependencies] @@ -7654,29 +7655,29 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruff" -version = "0.8.5" +version = "0.8.6" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.8.5-py3-none-linux_armv6l.whl", hash = "sha256:5ad11a5e3868a73ca1fa4727fe7e33735ea78b416313f4368c504dbeb69c0f88"}, - {file = "ruff-0.8.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f69ab37771ea7e0715fead8624ec42996d101269a96e31f4d31be6fc33aa19b7"}, - {file = "ruff-0.8.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b5462d7804558ccff9c08fe8cbf6c14b7efe67404316696a2dde48297b1925bb"}, - {file = "ruff-0.8.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d56de7220a35607f9fe59f8a6d018e14504f7b71d784d980835e20fc0611cd50"}, - {file = "ruff-0.8.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9d99cf80b0429cbebf31cbbf6f24f05a29706f0437c40413d950e67e2d4faca4"}, - {file = "ruff-0.8.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b75ac29715ac60d554a049dbb0ef3b55259076181c3369d79466cb130eb5afd"}, - {file = "ruff-0.8.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c9d526a62c9eda211b38463528768fd0ada25dad524cb33c0e99fcff1c67b5dc"}, - {file = "ruff-0.8.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:587c5e95007612c26509f30acc506c874dab4c4abbacd0357400bd1aa799931b"}, - {file = "ruff-0.8.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:622b82bf3429ff0e346835ec213aec0a04d9730480cbffbb6ad9372014e31bbd"}, - {file = "ruff-0.8.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f99be814d77a5dac8a8957104bdd8c359e85c86b0ee0e38dca447cb1095f70fb"}, - {file = "ruff-0.8.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01c048f9c3385e0fd7822ad0fd519afb282af9cf1778f3580e540629df89725"}, - {file = "ruff-0.8.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7512e8cb038db7f5db6aae0e24735ff9ea03bb0ed6ae2ce534e9baa23c1dc9ea"}, - {file = "ruff-0.8.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:762f113232acd5b768d6b875d16aad6b00082add40ec91c927f0673a8ec4ede8"}, - {file = "ruff-0.8.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:03a90200c5dfff49e4c967b405f27fdfa81594cbb7c5ff5609e42d7fe9680da5"}, - {file = "ruff-0.8.5-py3-none-win32.whl", hash = "sha256:8710ffd57bdaa6690cbf6ecff19884b8629ec2a2a2a2f783aa94b1cc795139ed"}, - {file = "ruff-0.8.5-py3-none-win_amd64.whl", hash = "sha256:4020d8bf8d3a32325c77af452a9976a9ad6455773bcb94991cf15bd66b347e47"}, - {file = "ruff-0.8.5-py3-none-win_arm64.whl", hash = "sha256:134ae019ef13e1b060ab7136e7828a6d83ea727ba123381307eb37c6bd5e01cb"}, - {file = "ruff-0.8.5.tar.gz", hash = "sha256:1098d36f69831f7ff2a1da3e6407d5fbd6dfa2559e4f74ff2d260c5588900317"}, + {file = "ruff-0.8.6-py3-none-linux_armv6l.whl", hash = "sha256:defed167955d42c68b407e8f2e6f56ba52520e790aba4ca707a9c88619e580e3"}, + {file = "ruff-0.8.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:54799ca3d67ae5e0b7a7ac234baa657a9c1784b48ec954a094da7c206e0365b1"}, + {file = "ruff-0.8.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e88b8f6d901477c41559ba540beeb5a671e14cd29ebd5683903572f4b40a9807"}, + {file = "ruff-0.8.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0509e8da430228236a18a677fcdb0c1f102dd26d5520f71f79b094963322ed25"}, + {file = "ruff-0.8.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91a7ddb221779871cf226100e677b5ea38c2d54e9e2c8ed847450ebbdf99b32d"}, + {file = "ruff-0.8.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:248b1fb3f739d01d528cc50b35ee9c4812aa58cc5935998e776bf8ed5b251e75"}, + {file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bc3c083c50390cf69e7e1b5a5a7303898966be973664ec0c4a4acea82c1d4315"}, + {file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52d587092ab8df308635762386f45f4638badb0866355b2b86760f6d3c076188"}, + {file = "ruff-0.8.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61323159cf21bc3897674e5adb27cd9e7700bab6b84de40d7be28c3d46dc67cf"}, + {file = "ruff-0.8.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ae4478b1471fc0c44ed52a6fb787e641a2ac58b1c1f91763bafbc2faddc5117"}, + {file = "ruff-0.8.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0c000a471d519b3e6cfc9c6680025d923b4ca140ce3e4612d1a2ef58e11f11fe"}, + {file = "ruff-0.8.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9257aa841e9e8d9b727423086f0fa9a86b6b420fbf4bf9e1465d1250ce8e4d8d"}, + {file = "ruff-0.8.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45a56f61b24682f6f6709636949ae8cc82ae229d8d773b4c76c09ec83964a95a"}, + {file = "ruff-0.8.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:496dd38a53aa173481a7d8866bcd6451bd934d06976a2505028a50583e001b76"}, + {file = "ruff-0.8.6-py3-none-win32.whl", hash = "sha256:e169ea1b9eae61c99b257dc83b9ee6c76f89042752cb2d83486a7d6e48e8f764"}, + {file = "ruff-0.8.6-py3-none-win_amd64.whl", hash = "sha256:f1d70bef3d16fdc897ee290d7d20da3cbe4e26349f62e8a0274e7a3f4ce7a905"}, + {file = "ruff-0.8.6-py3-none-win_arm64.whl", hash = "sha256:7d7fc2377a04b6e04ffe588caad613d0c460eb2ecba4c0ccbbfe2bc973cbc162"}, + {file = "ruff-0.8.6.tar.gz", hash = "sha256:dcad24b81b62650b0eb8814f576fc65cfee8674772a6e24c9b747911801eeaa5"}, ] [[package]] @@ -8292,22 +8293,22 @@ sqlcipher = ["sqlcipher3_binary"] [[package]] name = "sse-starlette" -version = "2.1.3" +version = "2.2.1" description = "SSE plugin for Starlette" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772"}, - {file = "sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169"}, + {file = "sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99"}, + {file = "sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419"}, ] [package.dependencies] -anyio = "*" -starlette = "*" -uvicorn = "*" +anyio = ">=4.7.0" +starlette = ">=0.41.3" [package.extras] examples = ["fastapi"] +uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "stack-data" @@ -10065,4 +10066,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "3c4cae19fcbd9183bde1bd88cea55454921281e26447d9a2c64404a5defffb3e" +content-hash = "5bc799fe999462a0345718452a628f140cc3594aa1a3002658764f0f571b8dee" diff --git a/pyproject.toml b/pyproject.toml index d94d979253f0e..c77451f9e0e93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,9 +60,9 @@ whatthepatch = "^1.0.6" protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+ opentelemetry-api = "1.25.0" opentelemetry-exporter-otlp-proto-grpc = "1.25.0" -modal = ">=0.66.26,<0.71.0" +modal = ">=0.66.26,<0.72.0" runloop-api-client = "0.11.0" -libtmux = "^0.37.0" +libtmux = ">=0.37,<0.40" pygithub = "^2.5.0" joblib = "*" openhands-aci = "0.1.6" @@ -82,7 +82,7 @@ voyageai = "*" llama-index-embeddings-voyageai = "*" [tool.poetry.group.dev.dependencies] -ruff = "0.8.5" +ruff = "0.8.6" mypy = "1.14.1" pre-commit = "4.0.1" build = "*" From d1555e093ccea20631895e73e4c46f89d55682c4 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 7 Jan 2025 19:46:03 +0400 Subject: [PATCH 11/17] chore(frontend): Close conversation card context menu when clicking elsewhere (#6111) --- .../conversation-card-context-menu.tsx | 27 +++++++++++++++++++ .../conversation-panel/conversation-card.tsx | 16 +++++------ 2 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx diff --git a/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx b/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx new file mode 100644 index 0000000000000..d2b290e000442 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx @@ -0,0 +1,27 @@ +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; +import { ContextMenu } from "../context-menu/context-menu"; +import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; + +interface ConversationCardContextMenuProps { + onClose: () => void; + onDelete: (event: React.MouseEvent) => void; +} + +export function ConversationCardContextMenu({ + onClose, + onDelete, +}: ConversationCardContextMenuProps) { + const ref = useClickOutsideElement(onClose); + + return ( + + + Delete + + + ); +} diff --git a/frontend/src/components/features/conversation-panel/conversation-card.tsx b/frontend/src/components/features/conversation-panel/conversation-card.tsx index c10e90f005ba2..5f44f5b527e2e 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card.tsx @@ -5,11 +5,10 @@ import { ProjectStatus, ConversationStateIndicator, } from "./conversation-state-indicator"; -import { ContextMenu } from "../context-menu/context-menu"; -import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; import { EllipsisButton } from "./ellipsis-button"; +import { ConversationCardContextMenu } from "./conversation-card-context-menu"; -interface ProjectCardProps { +interface ConversationCardProps { onClick: () => void; onDelete: () => void; onChangeTitle: (title: string) => void; @@ -27,7 +26,7 @@ export function ConversationCard({ selectedRepository, lastUpdatedAt, status = "STOPPED", -}: ProjectCardProps) { +}: ConversationCardProps) { const [contextMenuVisible, setContextMenuVisible] = React.useState(false); const inputRef = React.useRef(null); @@ -86,11 +85,10 @@ export function ConversationCard({
{contextMenuVisible && ( - - - Delete - - + setContextMenuVisible(false)} + onDelete={handleDelete} + /> )} {selectedRepository && ( Date: Tue, 7 Jan 2025 20:06:22 +0400 Subject: [PATCH 12/17] chore(deps-dev): bump @tanstack/eslint-plugin-query from 5.62.15 to 5.62.16 in /frontend in the eslint group (#6112) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aab2269cf542c..fba6000f71ef9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -52,7 +52,7 @@ "@playwright/test": "^1.49.1", "@react-router/dev": "^7.1.1", "@tailwindcss/typography": "^0.5.15", - "@tanstack/eslint-plugin-query": "^5.62.15", + "@tanstack/eslint-plugin-query": "^5.62.16", "@testing-library/jest-dom": "^6.6.1", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", @@ -5344,9 +5344,9 @@ } }, "node_modules/@tanstack/eslint-plugin-query": { - "version": "5.62.15", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.62.15.tgz", - "integrity": "sha512-24BHoF3LIzyptjrZXc1IpaISno+fhVD3zWWso/HPSB+ZVOyOXoiQSQc2K362T13JKJ07EInhHi1+KyNoRzCCfQ==", + "version": "5.62.16", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.62.16.tgz", + "integrity": "sha512-VhnHSQ/hc62olLzGhlLJ4BJGWynwjs3cDMsByasKJ3zjW1YZ+6raxOv0gHHISm+VEnAY42pkMowmSWrXfL4NTw==", "dev": true, "dependencies": { "@typescript-eslint/utils": "^8.18.1" diff --git a/frontend/package.json b/frontend/package.json index 9f519fa3524e3..bb28202c229f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -79,7 +79,7 @@ "@playwright/test": "^1.49.1", "@react-router/dev": "^7.1.1", "@tailwindcss/typography": "^0.5.15", - "@tanstack/eslint-plugin-query": "^5.62.15", + "@tanstack/eslint-plugin-query": "^5.62.16", "@testing-library/jest-dom": "^6.6.1", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", From e3a96097bac87e4e192c72ae1ae990afc0e6ee83 Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Tue, 7 Jan 2025 11:15:47 -0500 Subject: [PATCH 13/17] Remove leaked exception (#6086) Co-authored-by: openhands --- openhands/controller/agent_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands/controller/agent_controller.py b/openhands/controller/agent_controller.py index f94b95bb84bb1..f218716acd53c 100644 --- a/openhands/controller/agent_controller.py +++ b/openhands/controller/agent_controller.py @@ -217,7 +217,7 @@ async def _step_with_exception_handling(self): reported = RuntimeError( 'There was an unexpected error while running the agent. Please ' f'report this error to the developers. Your session ID is {self.id}. ' - f'Exception: {e}.' + f'Error type: {e.__class__.__name__}' ) if isinstance(e, litellm.AuthenticationError) or isinstance( e, litellm.BadRequestError From 9016b9c434095fa9ed96c79efee0ac28334926cc Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 7 Jan 2025 20:42:06 +0400 Subject: [PATCH 14/17] chore(frontend): Fix "confirm delete conversation" modal button colors (#6118) --- .../features/conversation-panel/confirm-delete-modal.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/features/conversation-panel/confirm-delete-modal.tsx b/frontend/src/components/features/conversation-panel/confirm-delete-modal.tsx index 2316ca1de7d34..4dd7c183be09d 100644 --- a/frontend/src/components/features/conversation-panel/confirm-delete-modal.tsx +++ b/frontend/src/components/features/conversation-panel/confirm-delete-modal.tsx @@ -25,10 +25,14 @@ export function ConfirmDeleteModal({
- +
From affbc49b084a2142a8fc2d9251acc99a719134ff Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Tue, 7 Jan 2025 11:42:41 -0500 Subject: [PATCH 15/17] fix for clone repo (#6116) --- openhands/runtime/base.py | 6 ++++-- openhands/server/routes/manage_conversations.py | 4 +--- openhands/server/session/agent_session.py | 5 ++++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index b2fbd53b21005..c079aae92fd1d 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -210,9 +210,11 @@ async def _handle_action(self, event: Action) -> None: source = event.source if event.source else EventSource.AGENT self.event_stream.add_event(observation, source) # type: ignore[arg-type] - def clone_repo(self, github_token: str | None, selected_repository: str | None): + def clone_repo(self, github_token: str, selected_repository: str): if not github_token or not selected_repository: - return + raise ValueError( + 'github_token and selected_repository must be provided to clone a repository' + ) url = f'https://{github_token}@github.com/{selected_repository}.git' dir_name = selected_repository.split('/')[1] # add random branch name to avoid conflicts diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 235b5801f24db..6d23ae0169138 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -30,9 +30,7 @@ class InitSessionRequest(BaseModel): github_token: str | None = None - latest_event_id: int = -1 selected_repository: str | None = None - args: dict | None = None @app.post('/conversations') @@ -53,7 +51,7 @@ async def new_conversation(request: Request, data: InitSessionRequest): session_init_args = {**settings.__dict__, **session_init_args} github_token = getattr(request.state, 'github_token', '') - session_init_args['github_token'] = github_token + session_init_args['github_token'] = github_token or data.github_token or '' session_init_args['selected_repository'] = data.selected_repository conversation_init_data = ConversationInitData(**session_init_args) logger.info('Loading conversation store') diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index 9f761a9298beb..3a0c96804cc2d 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -204,7 +204,10 @@ async def _create_runtime( ) return - self.runtime.clone_repo(github_token, selected_repository) + if selected_repository: + await call_sync_from_async( + self.runtime.clone_repo, github_token, selected_repository + ) if agent.prompt_manager: microagents: list[BaseMicroAgent] = await call_sync_from_async( self.runtime.get_microagents_from_selected_repo, selected_repository From 77aa843d5364e67cf3d163d9dfa3dd9999ef065a Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Tue, 7 Jan 2025 11:55:21 -0500 Subject: [PATCH 16/17] feat: support running docker runtime stresstest in CI (#6100) Co-authored-by: Boxuan Li --- openhands/core/config/sandbox_config.py | 5 ++++ .../runtime/impl/docker/docker_runtime.py | 6 ++--- tests/runtime/conftest.py | 2 ++ tests/runtime/test_stress_docker_runtime.py | 25 ++++++++++--------- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/openhands/core/config/sandbox_config.py b/openhands/core/config/sandbox_config.py index d0785a640a342..53dc3f77f9fcb 100644 --- a/openhands/core/config/sandbox_config.py +++ b/openhands/core/config/sandbox_config.py @@ -35,6 +35,10 @@ class SandboxConfig: remote_runtime_resource_factor: Factor to scale the resource allocation for remote runtime. Must be one of [1, 2, 4, 8]. Will only be used if the runtime is remote. enable_gpu: Whether to enable GPU. + docker_runtime_kwargs: Additional keyword arguments to pass to the Docker runtime when running containers. + This should be a JSON string that will be parsed into a dictionary. + Example in config.toml: + docker_runtime_kwargs = '{"mem_limit": "4g", "cpu_quota": 100000}' """ remote_runtime_api_url: str = 'http://localhost:8000' @@ -61,6 +65,7 @@ class SandboxConfig: close_delay: int = 900 remote_runtime_resource_factor: int = 1 enable_gpu: bool = False + docker_runtime_kwargs: str | None = None def defaults_to_dict(self) -> dict: """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional.""" diff --git a/openhands/runtime/impl/docker/docker_runtime.py b/openhands/runtime/impl/docker/docker_runtime.py index 852e50f617d15..b56d866fcb81d 100644 --- a/openhands/runtime/impl/docker/docker_runtime.py +++ b/openhands/runtime/impl/docker/docker_runtime.py @@ -267,13 +267,11 @@ def _init_container(self): environment=environment, volumes=volumes, device_requests=( - [docker.types.DeviceRequest( - capabilities=[['gpu']], - count=-1 - )] + [docker.types.DeviceRequest(capabilities=[['gpu']], count=-1)] if self.config.sandbox.enable_gpu else None ), + **(self.config.sandbox.docker_runtime_kwargs or {}), ) self.log('debug', f'Container started. Server url: {self.api_url}') self.send_status_message('STATUS$CONTAINER_STARTED') diff --git a/tests/runtime/conftest.py b/tests/runtime/conftest.py index 83038fa286660..a8ee81f945969 100644 --- a/tests/runtime/conftest.py +++ b/tests/runtime/conftest.py @@ -215,6 +215,7 @@ def _load_runtime( use_workspace: bool | None = None, force_rebuild_runtime: bool = False, runtime_startup_env_vars: dict[str, str] | None = None, + docker_runtime_kwargs: dict[str, str] | None = None, ) -> Runtime: sid = 'rt_' + str(random.randint(100000, 999999)) @@ -226,6 +227,7 @@ def _load_runtime( config.run_as_openhands = run_as_openhands config.sandbox.force_rebuild_runtime = force_rebuild_runtime config.sandbox.keep_runtime_alive = False + config.sandbox.docker_runtime_kwargs = docker_runtime_kwargs # Folder where all tests create their own folder global test_mount_path if use_workspace: diff --git a/tests/runtime/test_stress_docker_runtime.py b/tests/runtime/test_stress_docker_runtime.py index e7d196d482071..d0e141ee31421 100644 --- a/tests/runtime/test_stress_docker_runtime.py +++ b/tests/runtime/test_stress_docker_runtime.py @@ -1,18 +1,21 @@ """Stress tests for the DockerRuntime, which connects to the ActionExecutor running in the sandbox.""" -import pytest -from conftest import TEST_IN_CI, _close_test_runtime, _load_runtime +from conftest import _close_test_runtime, _load_runtime from openhands.core.logger import openhands_logger as logger from openhands.events.action import CmdRunAction -@pytest.mark.skipif( - TEST_IN_CI, - reason='This test should only be run locally, not in CI.', -) def test_stress_docker_runtime(temp_dir, runtime_cls, repeat=1): - runtime = _load_runtime(temp_dir, runtime_cls) + runtime = _load_runtime( + temp_dir, + runtime_cls, + docker_runtime_kwargs={ + 'cpu_period': 100000, # 100ms + 'cpu_quota': 100000, # Can use 100ms out of each 100ms period (1 CPU) + 'mem_limit': '4G', # 4 GB of memory + }, + ) action = CmdRunAction( command='sudo apt-get update && sudo apt-get install -y stress-ng' @@ -23,11 +26,9 @@ def test_stress_docker_runtime(temp_dir, runtime_cls, repeat=1): assert obs.exit_code == 0 for _ in range(repeat): - # run stress-ng stress tests for 5 minutes - # FIXME: this would make Docker daemon die, even though running this - # command on its own in the same container is fine - action = CmdRunAction(command='stress-ng --all 1 -t 5m') - action.timeout = 600 + # run stress-ng stress tests for 1 minute + action = CmdRunAction(command='stress-ng --all 1 -t 1m') + action.timeout = 120 logger.info(action, extra={'msg_type': 'ACTION'}) obs = runtime.run_action(action) logger.info(obs, extra={'msg_type': 'OBSERVATION'}) From cf0f6e5e3821574fd78c8a35a7f79223a29d9026 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 7 Jan 2025 21:51:03 +0400 Subject: [PATCH 17/17] Improve conversation panel (#6087) --- .../conversation-card.test.tsx | 16 +++- .../conversation-panel.test.tsx | 54 ++---------- .../features/conversation-panel/utils.ts | 12 +++ frontend/__tests__/routes/_oh.app.test.tsx | 1 + frontend/src/api/open-hands.types.ts | 1 + .../conversation-card-context-menu.tsx | 5 ++ .../conversation-panel/conversation-card.tsx | 19 +++- .../conversation-panel-wrapper.tsx | 22 +++++ .../conversation-panel/conversation-panel.tsx | 13 +-- .../components/features/sidebar/sidebar.tsx | 88 +++++++------------ .../features/sidebar/user-avatar.tsx | 19 ++-- .../shared/buttons/all-hands-logo-button.tsx | 2 +- .../shared/buttons/exit-project-button.tsx | 2 +- .../shared/buttons/settings-button.tsx | 4 +- .../shared/buttons/tooltip-button.tsx | 11 ++- frontend/src/mocks/handlers.ts | 23 +++-- frontend/src/routes/_oh._index/route.tsx | 5 +- frontend/src/routes/_oh/route.tsx | 5 +- .../server/routes/manage_conversations.py | 6 ++ 19 files changed, 162 insertions(+), 146 deletions(-) create mode 100644 frontend/__tests__/components/features/conversation-panel/utils.ts create mode 100644 frontend/src/components/features/conversation-panel/conversation-panel-wrapper.tsx diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx index e07eb25a3d00b..431e6a4f9f830 100644 --- a/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx +++ b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, test, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { formatTimeDelta } from "#/utils/format-time-delta"; import { ConversationCard } from "#/components/features/conversation-panel/conversation-card"; +import { clickOnEditButton } from "./utils"; describe("ConversationCard", () => { const onClick = vi.fn(); @@ -144,7 +145,9 @@ describe("ConversationCard", () => { />, ); - const selectedRepository = screen.getByTestId("conversation-card-selected-repository"); + const selectedRepository = screen.getByTestId( + "conversation-card-selected-repository", + ); await user.click(selectedRepository); expect(onClick).not.toHaveBeenCalled(); @@ -164,6 +167,14 @@ describe("ConversationCard", () => { ); const title = screen.getByTestId("conversation-card-title"); + expect(title).toBeDisabled(); + + await clickOnEditButton(user); + + expect(title).toBeEnabled(); + expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument(); + // expect to be focused + expect(document.activeElement).toBe(title); await user.clear(title); await user.type(title, "New Conversation Name "); @@ -171,6 +182,7 @@ describe("ConversationCard", () => { expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name"); expect(title).toHaveValue("New Conversation Name"); + expect(title).toBeDisabled(); }); it("should reset title and not call onChangeTitle when the title is empty", async () => { @@ -186,6 +198,8 @@ describe("ConversationCard", () => { />, ); + await clickOnEditButton(user); + const title = screen.getByTestId("conversation-card-title"); await user.clear(title); diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx index 262b2bd28e382..da22450c996b2 100644 --- a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx +++ b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx @@ -9,6 +9,7 @@ import userEvent from "@testing-library/user-event"; import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel"; import OpenHands from "#/api/open-hands"; import { AuthProvider } from "#/context/auth-context"; +import { clickOnEditButton } from "./utils"; describe("ConversationPanel", () => { const onCloseMock = vi.fn(); @@ -52,6 +53,8 @@ describe("ConversationPanel", () => { renderConversationPanel(); const cards = await screen.findAllByTestId("conversation-card"); + // NOTE that we filter out conversations that don't have a created_at property + // (mock data has 4 conversations, but only 3 have a created_at property) expect(cards).toHaveLength(3); }); @@ -169,6 +172,8 @@ describe("ConversationPanel", () => { const cards = await screen.findAllByTestId("conversation-card"); const title = within(cards[0]).getByTestId("conversation-card-title"); + await clickOnEditButton(user); + await user.clear(title); await user.type(title, "Conversation 1 Renamed"); await user.tab(); @@ -196,6 +201,8 @@ describe("ConversationPanel", () => { // Ensure the conversation is not renamed expect(updateUserConversationSpy).not.toHaveBeenCalled(); + await clickOnEditButton(user); + await user.type(title, "Conversation 1"); await user.click(title); await user.tab(); @@ -217,51 +224,4 @@ describe("ConversationPanel", () => { expect(onCloseMock).toHaveBeenCalledOnce(); }); - - describe("New Conversation Button", () => { - it("should display a confirmation modal when clicking", async () => { - const user = userEvent.setup(); - renderConversationPanel(); - - expect( - screen.queryByTestId("confirm-new-conversation-modal"), - ).not.toBeInTheDocument(); - - const newProjectButton = screen.getByTestId("new-conversation-button"); - await user.click(newProjectButton); - - const modal = screen.getByTestId("confirm-new-conversation-modal"); - expect(modal).toBeInTheDocument(); - }); - - it("should call endSession and close panel after confirming", async () => { - const user = userEvent.setup(); - renderConversationPanel(); - - const newProjectButton = screen.getByTestId("new-conversation-button"); - await user.click(newProjectButton); - - const confirmButton = screen.getByText("Confirm"); - await user.click(confirmButton); - - expect(endSessionMock).toHaveBeenCalledOnce(); - expect(onCloseMock).toHaveBeenCalledOnce(); - }); - - it("should close the modal when cancelling", async () => { - const user = userEvent.setup(); - renderConversationPanel(); - - const newProjectButton = screen.getByTestId("new-conversation-button"); - await user.click(newProjectButton); - - const cancelButton = screen.getByText("Cancel"); - await user.click(cancelButton); - - expect(endSessionMock).not.toHaveBeenCalled(); - expect( - screen.queryByTestId("confirm-new-conversation-modal"), - ).not.toBeInTheDocument(); - }); - }); }); diff --git a/frontend/__tests__/components/features/conversation-panel/utils.ts b/frontend/__tests__/components/features/conversation-panel/utils.ts new file mode 100644 index 0000000000000..5963dc0a08a72 --- /dev/null +++ b/frontend/__tests__/components/features/conversation-panel/utils.ts @@ -0,0 +1,12 @@ +import { screen, within } from "@testing-library/react"; +import { UserEvent } from "@testing-library/user-event"; + +export const clickOnEditButton = async (user: UserEvent) => { + const ellipsisButton = screen.getByTestId("ellipsis-button"); + await user.click(ellipsisButton); + + const menu = screen.getByTestId("context-menu"); + const editButton = within(menu).getByTestId("edit-button"); + + await user.click(editButton); +}; diff --git a/frontend/__tests__/routes/_oh.app.test.tsx b/frontend/__tests__/routes/_oh.app.test.tsx index e034c74765454..4fc96b25d3e5c 100644 --- a/frontend/__tests__/routes/_oh.app.test.tsx +++ b/frontend/__tests__/routes/_oh.app.test.tsx @@ -60,6 +60,7 @@ describe("App", () => { getConversationSpy.mockResolvedValue({ conversation_id: "9999", last_updated_at: "", + created_at: "", title: "", selected_repository: "", status: "STOPPED", diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index 263bfb7811628..169de47afb395 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/frontend/src/api/open-hands.types.ts @@ -65,6 +65,7 @@ export interface Conversation { title: string; selected_repository: string | null; last_updated_at: string; + created_at: string; status: ProjectStatus; } diff --git a/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx b/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx index d2b290e000442..d22c74cbb14eb 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx @@ -5,11 +5,13 @@ import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; interface ConversationCardContextMenuProps { onClose: () => void; onDelete: (event: React.MouseEvent) => void; + onEdit: (event: React.MouseEvent) => void; } export function ConversationCardContextMenu({ onClose, onDelete, + onEdit, }: ConversationCardContextMenuProps) { const ref = useClickOutsideElement(onClose); @@ -22,6 +24,9 @@ export function ConversationCardContextMenu({ Delete + + Edit Title + ); } diff --git a/frontend/src/components/features/conversation-panel/conversation-card.tsx b/frontend/src/components/features/conversation-panel/conversation-card.tsx index 5f44f5b527e2e..ba53740f805c9 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card.tsx @@ -28,6 +28,7 @@ export function ConversationCard({ status = "STOPPED", }: ConversationCardProps) { const [contextMenuVisible, setContextMenuVisible] = React.useState(false); + const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view"); const inputRef = React.useRef(null); const handleBlur = () => { @@ -39,6 +40,8 @@ export function ConversationCard({ // reset the value if it's empty inputRef.current!.value = title; } + + setTitleMode("view"); }; const handleKeyUp = (event: React.KeyboardEvent) => { @@ -56,6 +59,18 @@ export function ConversationCard({ onDelete(); }; + const handleEdit = (event: React.MouseEvent) => { + event.stopPropagation(); + setTitleMode("edit"); + setContextMenuVisible(false); + }; + + React.useEffect(() => { + if (titleMode === "edit") { + inputRef.current?.focus(); + } + }, [titleMode]); + return (
setContextMenuVisible(false)} onDelete={handleDelete} + onEdit={handleEdit} /> )} {selectedRepository && ( diff --git a/frontend/src/components/features/conversation-panel/conversation-panel-wrapper.tsx b/frontend/src/components/features/conversation-panel/conversation-panel-wrapper.tsx new file mode 100644 index 0000000000000..0e11e6a3256d9 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/conversation-panel-wrapper.tsx @@ -0,0 +1,22 @@ +import ReactDOM from "react-dom"; + +interface ConversationPanelWrapperProps { + isOpen: boolean; +} + +export function ConversationPanelWrapper({ + isOpen, + children, +}: React.PropsWithChildren) { + if (!isOpen) return null; + + const portalTarget = document.getElementById("root-outlet"); + if (!portalTarget) return null; + + return ReactDOM.createPortal( +
+ {children} +
, + portalTarget, + ); +} diff --git a/frontend/src/components/features/conversation-panel/conversation-panel.tsx b/frontend/src/components/features/conversation-panel/conversation-panel.tsx index fd22cb74399b4..3510388ca13fa 100644 --- a/frontend/src/components/features/conversation-panel/conversation-panel.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-panel.tsx @@ -1,14 +1,14 @@ import React from "react"; -import { useLocation, useNavigate, useParams } from "react-router"; +import { useNavigate, useParams } from "react-router"; import { ConversationCard } from "./conversation-card"; import { useUserConversations } from "#/hooks/query/use-user-conversations"; import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation"; import { ConfirmDeleteModal } from "./confirm-delete-modal"; -import { NewConversationButton } from "./new-conversation-button"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation"; import { useEndSession } from "#/hooks/use-end-session"; import { ExitConversationModal } from "./exit-conversation-modal"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; interface ConversationPanelProps { onClose: () => void; @@ -17,9 +17,8 @@ interface ConversationPanelProps { export function ConversationPanel({ onClose }: ConversationPanelProps) { const { conversationId: cid } = useParams(); const navigate = useNavigate(); - const location = useLocation(); - const endSession = useEndSession(); + const ref = useClickOutsideElement(onClose); const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = React.useState(false); @@ -71,15 +70,11 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) { return (
- {location.pathname.startsWith("/conversation") && ( - setConfirmExitConversationModalVisible(true)} - /> - )} {isFetching && }
{error && ( diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index 1123756504872..6b7db215841a6 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { useLocation } from "react-router"; import { FaListUl } from "react-icons/fa"; +import { useDispatch } from "react-redux"; import { useAuth } from "#/context/auth-context"; import { useGitHubUser } from "#/hooks/query/use-github-user"; import { useIsAuthed } from "#/hooks/query/use-is-authed"; @@ -11,15 +11,20 @@ import { ExitProjectButton } from "#/components/shared/buttons/exit-project-butt import { SettingsButton } from "#/components/shared/buttons/settings-button"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal"; -import { ExitProjectConfirmationModal } from "#/components/shared/modals/exit-project-confirmation-modal"; import { SettingsModal } from "#/components/shared/modals/settings/settings-modal"; import { useSettingsUpToDate } from "#/context/settings-up-to-date-context"; import { useSettings } from "#/hooks/query/use-settings"; import { ConversationPanel } from "../conversation-panel/conversation-panel"; import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags"; +import { useEndSession } from "#/hooks/use-end-session"; +import { setCurrentAgentState } from "#/state/agent-slice"; +import { AgentState } from "#/types/agent-state"; +import { TooltipButton } from "#/components/shared/buttons/tooltip-button"; +import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper"; export function Sidebar() { - const location = useLocation(); + const dispatch = useDispatch(); + const endSession = useEndSession(); const user = useGitHubUser(); const { data: isAuthed } = useIsAuthed(); const { logout } = useAuth(); @@ -29,20 +34,9 @@ export function Sidebar() { const [accountSettingsModalOpen, setAccountSettingsModalOpen] = React.useState(false); const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false); - const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] = - React.useState(false); + const [conversationPanelIsOpen, setConversationPanelIsOpen] = React.useState(false); - const conversationPanelRef = React.useRef(null); - - const handleClick = (event: MouseEvent) => { - const conversationPanel = conversationPanelRef.current; - if (conversationPanelIsOpen && conversationPanel) { - if (!conversationPanel.contains(event.target as Node)) { - setConversationPanelIsOpen(false); - } - } - }; React.useEffect(() => { // If the github token is invalid, open the account settings modal again @@ -51,12 +45,10 @@ export function Sidebar() { } }, [user.isError]); - React.useEffect(() => { - document.addEventListener("click", handleClick); - return () => { - document.removeEventListener("click", handleClick); - }; - }, [conversationPanelIsOpen]); + const handleEndSession = () => { + dispatch(setCurrentAgentState(AgentState.LOADING)); + endSession(); + }; const handleAccountSettingsModalClose = () => { // If the user closes the modal without connecting to GitHub, @@ -66,22 +58,30 @@ export function Sidebar() { setAccountSettingsModalOpen(false); }; - const handleClickLogo = () => { - if (location.pathname.startsWith("/conversations/")) - setStartNewProjectModalIsOpen(true); - }; - const showSettingsModal = isAuthed && (!settingsAreUpToDate || settingsModalIsOpen); return ( <> -