From 52723061b1323e20751afe32adfa4b759e7335a9 Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Thu, 20 Feb 2025 10:50:17 -0500 Subject: [PATCH] Add conversation age limit configuration (#6763) Co-authored-by: openhands --- openhands/core/config/app_config.py | 1 + .../server/routes/manage_conversations.py | 18 +++- tests/unit/test_conversation.py | 88 ++++++++++++------- 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/openhands/core/config/app_config.py b/openhands/core/config/app_config.py index 8c995d1ee3..5965f06480 100644 --- a/openhands/core/config/app_config.py +++ b/openhands/core/config/app_config.py @@ -76,6 +76,7 @@ class AppConfig(BaseModel): file_uploads_allowed_extensions: list[str] = Field(default_factory=lambda: ['.*']) runloop_api_key: SecretStr | None = Field(default=None) cli_multiline_input: bool = Field(default=False) + conversation_max_age_seconds: int = Field(default=864000) # 10 days in seconds defaults_dict: ClassVar[dict] = {} diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 0209fcbae4..7711cbb9e8 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -130,8 +130,9 @@ async def _create_new_conversation( @app.post('/conversations') async def new_conversation(request: Request, data: InitSessionRequest): """Initialize a new session or join an existing one. + After successful initialization, the client should connect to the WebSocket - using the returned conversation ID + using the returned conversation ID. """ logger.info('Initializing new conversation') user_id = get_user_id(request) @@ -188,10 +189,19 @@ async def search_conversations( config, get_user_id(request) ) conversation_metadata_result_set = await conversation_store.search(page_id, limit) + + # Filter out conversations older than max_age + now = datetime.now(timezone.utc) + max_age = config.conversation_max_age_seconds + filtered_results = [ + conversation for conversation in conversation_metadata_result_set.results + if hasattr(conversation, 'created_at') and + (now - conversation.created_at.replace(tzinfo=timezone.utc)).total_seconds() <= max_age + ] + conversation_ids = set( conversation.conversation_id - for conversation in conversation_metadata_result_set.results - if hasattr(conversation, 'created_at') + for conversation in filtered_results ) running_conversations = await conversation_manager.get_running_agent_loops( get_user_id(request), set(conversation_ids) @@ -202,7 +212,7 @@ async def search_conversations( conversation=conversation, is_running=conversation.conversation_id in running_conversations, ) - for conversation in conversation_metadata_result_set.results + for conversation in filtered_results ), next_page_id=conversation_metadata_result_set.next_page_id, ) diff --git a/tests/unit/test_conversation.py b/tests/unit/test_conversation.py index 5efb44294a..e9e61aa88c 100644 --- a/tests/unit/test_conversation.py +++ b/tests/unit/test_conversation.py @@ -1,10 +1,11 @@ import json from contextlib import contextmanager -from datetime import datetime +from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest +from openhands.runtime.impl.docker.docker_runtime import DockerRuntime from openhands.server.routes.manage_conversations import ( delete_conversation, get_conversation, @@ -30,8 +31,8 @@ def _patch_store(): 'selected_repository': 'foobar', 'conversation_id': 'some_conversation_id', 'github_user_id': '12345', - 'created_at': '2025-01-01T00:00:00', - 'last_updated_at': '2025-01-01T00:01:00', + 'created_at': '2025-01-01T00:00:00+00:00', + 'last_updated_at': '2025-01-01T00:01:00+00:00', } ), ) @@ -49,22 +50,46 @@ def _patch_store(): @pytest.mark.asyncio async def test_search_conversations(): with _patch_store(): - result_set = await search_conversations( - MagicMock(state=MagicMock(github_token='')) - ) - expected = ConversationInfoResultSet( - results=[ - ConversationInfo( - conversation_id='some_conversation_id', - title='Some Conversation', - created_at=datetime.fromisoformat('2025-01-01T00:00:00'), - last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00'), - status=ConversationStatus.STOPPED, - selected_repository='foobar', - ) - ] - ) - assert result_set == expected + with patch( + 'openhands.server.routes.manage_conversations.config' + ) as mock_config: + mock_config.conversation_max_age_seconds = 864000 # 10 days + with patch( + 'openhands.server.routes.manage_conversations.conversation_manager' + ) as mock_manager: + + async def mock_get_running_agent_loops(*args, **kwargs): + return set() + + mock_manager.get_running_agent_loops = mock_get_running_agent_loops + with patch( + 'openhands.server.routes.manage_conversations.datetime' + ) as mock_datetime: + mock_datetime.now.return_value = datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ) + mock_datetime.fromisoformat = datetime.fromisoformat + mock_datetime.timezone = timezone + result_set = await search_conversations( + MagicMock(state=MagicMock(github_token='')) + ) + expected = ConversationInfoResultSet( + results=[ + ConversationInfo( + conversation_id='some_conversation_id', + title='Some Conversation', + created_at=datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ), + last_updated_at=datetime.fromisoformat( + '2025-01-01T00:01:00+00:00' + ), + status=ConversationStatus.STOPPED, + selected_repository='foobar', + ) + ] + ) + assert result_set == expected @pytest.mark.asyncio @@ -76,8 +101,8 @@ async def test_get_conversation(): expected = ConversationInfo( conversation_id='some_conversation_id', title='Some Conversation', - created_at=datetime.fromisoformat('2025-01-01T00:00:00'), - last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00'), + created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'), + last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'), status=ConversationStatus.STOPPED, selected_repository='foobar', ) @@ -109,8 +134,8 @@ async def test_update_conversation(): expected = ConversationInfo( conversation_id='some_conversation_id', title='New Title', - created_at=datetime.fromisoformat('2025-01-01T00:00:00'), - last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00'), + created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'), + last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'), status=ConversationStatus.STOPPED, selected_repository='foobar', ) @@ -120,11 +145,12 @@ async def test_update_conversation(): @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 + with patch.object(DockerRuntime, 'delete', return_value=None): + 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