From f258eafa374eb38f9f9f1a2a0779b87adcd72614 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:36:56 +0700 Subject: [PATCH] feat(backend): add support for updating the title in V1 conversations (#11446) --- .../live_status_app_conversation_service.py | 69 ++- .../server/routes/manage_conversations.py | 261 +++++++++--- .../server/routes/test_conversation_routes.py | 393 ++++++++++++++++++ 3 files changed, 666 insertions(+), 57 deletions(-) diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 6941b32715..7c0f520f69 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -279,13 +279,15 @@ class LiveStatusAppConversationService(GitAppConversationService): # Build app_conversation from info result = [ - self._build_conversation( - app_conversation_info, - sandboxes_by_id.get(app_conversation_info.sandbox_id), - conversation_info_by_id.get(app_conversation_info.id), + ( + self._build_conversation( + app_conversation_info, + sandboxes_by_id.get(app_conversation_info.sandbox_id), + conversation_info_by_id.get(app_conversation_info.id), + ) + if app_conversation_info + else None ) - if app_conversation_info - else None for app_conversation_info in app_conversation_infos ] @@ -369,7 +371,6 @@ class LiveStatusAppConversationService(GitAppConversationService): self, task: AppConversationStartTask ) -> AsyncGenerator[AppConversationStartTask, None]: """Wait for sandbox to start and return info.""" - # Get the sandbox if not task.request.sandbox_id: sandbox = await self.sandbox_service.start_sandbox() @@ -472,14 +473,62 @@ class LiveStatusAppConversationService(GitAppConversationService): conversation_id=conversation_id, agent=agent, workspace=workspace, - confirmation_policy=AlwaysConfirm() - if user.confirmation_mode - else NeverConfirm(), + confirmation_policy=( + AlwaysConfirm() if user.confirmation_mode else NeverConfirm() + ), initial_message=initial_message, secrets=secrets, ) return start_conversation_request + async def update_agent_server_conversation_title( + self, + conversation_id: str, + new_title: str, + app_conversation_info: AppConversationInfo, + ) -> None: + """Update the conversation title in the agent-server. + + Args: + conversation_id: The conversation ID as a string + new_title: The new title to set + app_conversation_info: The app conversation info containing sandbox_id + """ + # Get the sandbox info to find the agent-server URL + sandbox = await self.sandbox_service.get_sandbox( + app_conversation_info.sandbox_id + ) + assert sandbox is not None, ( + f'Sandbox {app_conversation_info.sandbox_id} not found for conversation {conversation_id}' + ) + assert sandbox.exposed_urls is not None, ( + f'Sandbox {app_conversation_info.sandbox_id} has no exposed URLs for conversation {conversation_id}' + ) + + # Use the existing method to get the agent-server URL + agent_server_url = self._get_agent_server_url(sandbox) + + # Prepare the request + url = f'{agent_server_url.rstrip("/")}/api/conversations/{conversation_id}' + headers = {} + if sandbox.session_api_key: + headers['X-Session-API-Key'] = sandbox.session_api_key + + payload = {'title': new_title} + + # Make the PATCH request to the agent-server + response = await self.httpx_client.patch( + url, + json=payload, + headers=headers, + timeout=30.0, + ) + response.raise_for_status() + + _logger.info( + f'Successfully updated agent-server conversation {conversation_id} title to "{new_title}"' + ) + class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): sandbox_startup_timeout: int = Field( diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 20e828056a..5bb2fcb6b6 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -12,6 +12,9 @@ from fastapi.responses import JSONResponse from jinja2 import Environment, FileSystemLoader from pydantic import BaseModel, ConfigDict, Field +from openhands.app_server.app_conversation.app_conversation_info_service import ( + AppConversationInfoService, +) from openhands.app_server.app_conversation.app_conversation_models import ( AppConversation, ) @@ -19,6 +22,7 @@ from openhands.app_server.app_conversation.app_conversation_service import ( AppConversationService, ) from openhands.app_server.config import ( + depends_app_conversation_info_service, depends_app_conversation_service, ) from openhands.core.config.llm_config import LLMConfig @@ -90,6 +94,7 @@ from openhands.utils.conversation_summary import get_default_conversation_title app = APIRouter(prefix='/api', dependencies=get_dependencies()) app_conversation_service_dependency = depends_app_conversation_service() +app_conversation_info_service_dependency = depends_app_conversation_info_service() def _filter_conversations_by_age( @@ -759,23 +764,201 @@ class UpdateConversationRequest(BaseModel): model_config = ConfigDict(extra='forbid') +async def _update_v1_conversation( + conversation_uuid: uuid.UUID, + new_title: str, + user_id: str | None, + app_conversation_info_service: AppConversationInfoService, + app_conversation_service: AppConversationService, +) -> JSONResponse | bool: + """Update a V1 conversation title. + + Args: + conversation_uuid: The conversation ID as a UUID + new_title: The new title to set + user_id: The authenticated user ID + app_conversation_info_service: The app conversation info service + app_conversation_service: The app conversation service for agent-server communication + + Returns: + JSONResponse on error, True on success + """ + conversation_id = str(conversation_uuid) + logger.info( + f'Updating V1 conversation {conversation_uuid}', + extra={'session_id': conversation_id, 'user_id': user_id}, + ) + + # Get the V1 conversation info + app_conversation_info = ( + await app_conversation_info_service.get_app_conversation_info(conversation_uuid) + ) + + if not app_conversation_info: + # Not a V1 conversation + return None + + # Validate that the user owns this conversation + if user_id and app_conversation_info.created_by_user_id != user_id: + logger.warning( + f'User {user_id} attempted to update V1 conversation {conversation_uuid} owned by {app_conversation_info.created_by_user_id}', + extra={'session_id': conversation_id, 'user_id': user_id}, + ) + return JSONResponse( + content={ + 'status': 'error', + 'message': 'Permission denied: You can only update your own conversations', + 'msg_id': 'AUTHORIZATION$PERMISSION_DENIED', + }, + status_code=status.HTTP_403_FORBIDDEN, + ) + + # Update the title and timestamp + original_title = app_conversation_info.title + app_conversation_info.title = new_title + app_conversation_info.updated_at = datetime.now(timezone.utc) + + # Save the updated conversation info + try: + await app_conversation_info_service.save_app_conversation_info( + app_conversation_info + ) + except AssertionError: + # This happens when user doesn't own the conversation + logger.warning( + f'User {user_id} attempted to update V1 conversation {conversation_uuid} - permission denied', + extra={'session_id': conversation_id, 'user_id': user_id}, + ) + return JSONResponse( + content={ + 'status': 'error', + 'message': 'Permission denied: You can only update your own conversations', + 'msg_id': 'AUTHORIZATION$PERMISSION_DENIED', + }, + status_code=status.HTTP_403_FORBIDDEN, + ) + + # Try to update the agent-server as well + try: + if hasattr(app_conversation_service, 'update_agent_server_conversation_title'): + await app_conversation_service.update_agent_server_conversation_title( + conversation_id=conversation_id, + new_title=new_title, + app_conversation_info=app_conversation_info, + ) + except Exception as e: + # Log the error but don't fail the database update + logger.warning( + f'Failed to update agent-server for conversation {conversation_uuid}: {e}', + extra={'session_id': conversation_id, 'user_id': user_id}, + ) + + logger.info( + f'Successfully updated V1 conversation {conversation_uuid} title from "{original_title}" to "{app_conversation_info.title}"', + extra={'session_id': conversation_id, 'user_id': user_id}, + ) + + return True + + +async def _update_v0_conversation( + conversation_id: str, + new_title: str, + user_id: str | None, + conversation_store: ConversationStore, +) -> JSONResponse | bool: + """Update a V0 conversation title. + + Args: + conversation_id: The conversation ID + new_title: The new title to set + user_id: The authenticated user ID + conversation_store: The conversation store + + Returns: + JSONResponse on error, True on success + + Raises: + FileNotFoundError: If the conversation is not found + """ + logger.info( + f'Updating V0 conversation {conversation_id}', + extra={'session_id': conversation_id, 'user_id': user_id}, + ) + + # Get the existing conversation metadata + metadata = await conversation_store.get_metadata(conversation_id) + + # Validate that the user owns this conversation + if user_id and metadata.user_id != user_id: + logger.warning( + f'User {user_id} attempted to update conversation {conversation_id} owned by {metadata.user_id}', + extra={'session_id': conversation_id, 'user_id': user_id}, + ) + return JSONResponse( + content={ + 'status': 'error', + 'message': 'Permission denied: You can only update your own conversations', + 'msg_id': 'AUTHORIZATION$PERMISSION_DENIED', + }, + status_code=status.HTTP_403_FORBIDDEN, + ) + + # Update the conversation metadata + original_title = metadata.title + metadata.title = new_title + metadata.last_updated_at = datetime.now(timezone.utc) + + # Save the updated metadata + await conversation_store.save_metadata(metadata) + + # Emit a status update to connected clients about the title change + try: + status_update_dict = { + 'status_update': True, + 'type': 'info', + 'message': conversation_id, + 'conversation_title': metadata.title, + } + await conversation_manager.sio.emit( + 'oh_event', + status_update_dict, + to=f'room:{conversation_id}', + ) + except Exception as e: + logger.error(f'Error emitting title update event: {e}') + # Don't fail the update if we can't emit the event + + logger.info( + f'Successfully updated conversation {conversation_id} title from "{original_title}" to "{metadata.title}"', + extra={'session_id': conversation_id, 'user_id': user_id}, + ) + + return True + + @app.patch('/conversations/{conversation_id}') async def update_conversation( data: UpdateConversationRequest, conversation_id: str = Depends(validate_conversation_id), user_id: str | None = Depends(get_user_id), conversation_store: ConversationStore = Depends(get_conversation_store), + app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency, + app_conversation_service: AppConversationService = app_conversation_service_dependency, ) -> bool: """Update conversation metadata. This endpoint allows updating conversation details like title. Only the conversation owner can update the conversation. + Supports both V0 and V1 conversations. Args: conversation_id: The ID of the conversation to update data: The conversation update data (title, etc.) user_id: The authenticated user ID conversation_store: The conversation store dependency + app_conversation_info_service: The app conversation info service for V1 conversations + app_conversation_service: The app conversation service for agent-server communication Returns: bool: True if the conversation was updated successfully @@ -788,57 +971,41 @@ async def update_conversation( extra={'session_id': conversation_id, 'user_id': user_id}, ) + new_title = data.title.strip() + + # Try to handle as V1 conversation first try: - # Get the existing conversation metadata - metadata = await conversation_store.get_metadata(conversation_id) - - # Validate that the user owns this conversation - if user_id and metadata.user_id != user_id: - logger.warning( - f'User {user_id} attempted to update conversation {conversation_id} owned by {metadata.user_id}', - extra={'session_id': conversation_id, 'user_id': user_id}, - ) - return JSONResponse( - content={ - 'status': 'error', - 'message': 'Permission denied: You can only update your own conversations', - 'msg_id': 'AUTHORIZATION$PERMISSION_DENIED', - }, - status_code=status.HTTP_403_FORBIDDEN, - ) - - # Update the conversation metadata - original_title = metadata.title - metadata.title = data.title.strip() - metadata.last_updated_at = datetime.now(timezone.utc) - - # Save the updated metadata - await conversation_store.save_metadata(metadata) - - # Emit a status update to connected clients about the title change - try: - status_update_dict = { - 'status_update': True, - 'type': 'info', - 'message': conversation_id, - 'conversation_title': metadata.title, - } - await conversation_manager.sio.emit( - 'oh_event', - status_update_dict, - to=f'room:{conversation_id}', - ) - except Exception as e: - logger.error(f'Error emitting title update event: {e}') - # Don't fail the update if we can't emit the event - - logger.info( - f'Successfully updated conversation {conversation_id} title from "{original_title}" to "{metadata.title}"', - extra={'session_id': conversation_id, 'user_id': user_id}, + conversation_uuid = uuid.UUID(conversation_id) + result = await _update_v1_conversation( + conversation_uuid=conversation_uuid, + new_title=new_title, + user_id=user_id, + app_conversation_info_service=app_conversation_info_service, + app_conversation_service=app_conversation_service, ) - return True + # If result is not None, it's a V1 conversation (either success or error) + if result is not None: + return result + except (ValueError, TypeError): + # Not a valid UUID, fall through to V0 logic + pass + except Exception as e: + logger.warning( + f'Error checking V1 conversation {conversation_id}: {str(e)}', + extra={'session_id': conversation_id, 'user_id': user_id}, + ) + # Fall through to V0 logic + + # Handle as V0 conversation + try: + return await _update_v0_conversation( + conversation_id=conversation_id, + new_title=new_title, + user_id=user_id, + conversation_store=conversation_store, + ) except FileNotFoundError: logger.warning( f'Conversation {conversation_id} not found for update', diff --git a/tests/unit/server/routes/test_conversation_routes.py b/tests/unit/server/routes/test_conversation_routes.py index 05a1a26f5e..f909e44cc8 100644 --- a/tests/unit/server/routes/test_conversation_routes.py +++ b/tests/unit/server/routes/test_conversation_routes.py @@ -1,11 +1,18 @@ import json from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 import pytest from fastapi import status from fastapi.responses import JSONResponse +from openhands.app_server.app_conversation.app_conversation_info_service import ( + AppConversationInfoService, +) +from openhands.app_server.app_conversation.app_conversation_models import ( + AppConversationInfo, +) from openhands.microagent.microagent import KnowledgeMicroagent, RepoMicroagent from openhands.microagent.types import MicroagentMetadata, MicroagentType from openhands.server.routes.conversation import ( @@ -625,6 +632,392 @@ async def test_update_conversation_no_user_id_no_metadata_user_id(): mock_conversation_store.save_metadata.assert_called_once() +@pytest.mark.update_conversation +@pytest.mark.asyncio +async def test_update_v1_conversation_success(): + """Test successful V1 conversation update.""" + # Mock data + conversation_uuid = uuid4() + conversation_id = str(conversation_uuid) + user_id = 'test_user_456' + original_title = 'Original V1 Title' + new_title = 'Updated V1 Title' + + # Create mock V1 conversation info + mock_app_conversation_info = AppConversationInfo( + id=conversation_uuid, + created_by_user_id=user_id, + sandbox_id='test_sandbox_123', + title=original_title, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Create mock app conversation info service + mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService) + mock_app_conversation_info_service.get_app_conversation_info = AsyncMock( + return_value=mock_app_conversation_info + ) + mock_app_conversation_info_service.save_app_conversation_info = AsyncMock( + return_value=mock_app_conversation_info + ) + + # Create mock conversation store (won't be used for V1) + mock_conversation_store = MagicMock(spec=ConversationStore) + + # Create update request + update_request = UpdateConversationRequest(title=new_title) + + # Call the function + result = await update_conversation( + conversation_id=conversation_id, + data=update_request, + user_id=user_id, + conversation_store=mock_conversation_store, + app_conversation_info_service=mock_app_conversation_info_service, + ) + + # Verify the result + assert result is True + + # Verify V1 service was called + mock_app_conversation_info_service.get_app_conversation_info.assert_called_once_with( + conversation_uuid + ) + mock_app_conversation_info_service.save_app_conversation_info.assert_called_once() + + # Verify the conversation store was NOT called (V1 doesn't use it) + mock_conversation_store.get_metadata.assert_not_called() + + # Verify the saved info has updated title + saved_info = ( + mock_app_conversation_info_service.save_app_conversation_info.call_args[0][0] + ) + assert saved_info.title == new_title.strip() + assert saved_info.updated_at is not None + + +@pytest.mark.update_conversation +@pytest.mark.asyncio +async def test_update_v1_conversation_not_found(): + """Test V1 conversation update when conversation doesn't exist.""" + conversation_uuid = uuid4() + conversation_id = str(conversation_uuid) + user_id = 'test_user_456' + + # Create mock app conversation info service that returns None + mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService) + mock_app_conversation_info_service.get_app_conversation_info = AsyncMock( + return_value=None + ) + + # Create mock conversation store that also raises FileNotFoundError + mock_conversation_store = MagicMock(spec=ConversationStore) + mock_conversation_store.get_metadata = AsyncMock(side_effect=FileNotFoundError()) + + # Create update request + update_request = UpdateConversationRequest(title='New Title') + + # Call the function + result = await update_conversation( + conversation_id=conversation_id, + data=update_request, + user_id=user_id, + conversation_store=mock_conversation_store, + app_conversation_info_service=mock_app_conversation_info_service, + ) + + # Verify the result is a 404 error response + assert isinstance(result, JSONResponse) + assert result.status_code == status.HTTP_404_NOT_FOUND + + # Parse the JSON content + content = json.loads(result.body) + assert content['status'] == 'error' + assert content['message'] == 'Conversation not found' + assert content['msg_id'] == 'CONVERSATION$NOT_FOUND' + + +@pytest.mark.update_conversation +@pytest.mark.asyncio +async def test_update_v1_conversation_permission_denied(): + """Test V1 conversation update when user doesn't own the conversation.""" + conversation_uuid = uuid4() + conversation_id = str(conversation_uuid) + user_id = 'test_user_456' + owner_id = 'different_user_789' + + # Create mock V1 conversation info owned by different user + mock_app_conversation_info = AppConversationInfo( + id=conversation_uuid, + created_by_user_id=owner_id, + sandbox_id='test_sandbox_123', + title='Original Title', + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Create mock app conversation info service + mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService) + mock_app_conversation_info_service.get_app_conversation_info = AsyncMock( + return_value=mock_app_conversation_info + ) + + # Create mock conversation store (won't be used) + mock_conversation_store = MagicMock(spec=ConversationStore) + + # Create update request + update_request = UpdateConversationRequest(title='New Title') + + # Call the function + result = await update_conversation( + conversation_id=conversation_id, + data=update_request, + user_id=user_id, + conversation_store=mock_conversation_store, + app_conversation_info_service=mock_app_conversation_info_service, + ) + + # Verify the result is a 403 error response + assert isinstance(result, JSONResponse) + assert result.status_code == status.HTTP_403_FORBIDDEN + + # Parse the JSON content + content = json.loads(result.body) + assert content['status'] == 'error' + assert ( + content['message'] + == 'Permission denied: You can only update your own conversations' + ) + assert content['msg_id'] == 'AUTHORIZATION$PERMISSION_DENIED' + + # Verify save was NOT called + mock_app_conversation_info_service.save_app_conversation_info.assert_not_called() + + +@pytest.mark.update_conversation +@pytest.mark.asyncio +async def test_update_v1_conversation_save_assertion_error(): + """Test V1 conversation update when save raises AssertionError (permission check).""" + conversation_uuid = uuid4() + conversation_id = str(conversation_uuid) + user_id = 'test_user_456' + + # Create mock V1 conversation info + mock_app_conversation_info = AppConversationInfo( + id=conversation_uuid, + created_by_user_id=user_id, + sandbox_id='test_sandbox_123', + title='Original Title', + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Create mock app conversation info service + mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService) + mock_app_conversation_info_service.get_app_conversation_info = AsyncMock( + return_value=mock_app_conversation_info + ) + # Simulate AssertionError on save (permission check in service) + mock_app_conversation_info_service.save_app_conversation_info = AsyncMock( + side_effect=AssertionError('User does not own conversation') + ) + + # Create mock conversation store (won't be used) + mock_conversation_store = MagicMock(spec=ConversationStore) + + # Create update request + update_request = UpdateConversationRequest(title='New Title') + + # Call the function + result = await update_conversation( + conversation_id=conversation_id, + data=update_request, + user_id=user_id, + conversation_store=mock_conversation_store, + app_conversation_info_service=mock_app_conversation_info_service, + ) + + # Verify the result is a 403 error response + assert isinstance(result, JSONResponse) + assert result.status_code == status.HTTP_403_FORBIDDEN + + # Parse the JSON content + content = json.loads(result.body) + assert content['status'] == 'error' + assert ( + content['message'] + == 'Permission denied: You can only update your own conversations' + ) + assert content['msg_id'] == 'AUTHORIZATION$PERMISSION_DENIED' + + +@pytest.mark.update_conversation +@pytest.mark.asyncio +async def test_update_v1_conversation_title_whitespace_trimming(): + """Test that V1 conversation title is properly trimmed of whitespace.""" + conversation_uuid = uuid4() + conversation_id = str(conversation_uuid) + user_id = 'test_user_456' + title_with_whitespace = ' Trimmed V1 Title ' + expected_title = 'Trimmed V1 Title' + + # Create mock V1 conversation info + mock_app_conversation_info = AppConversationInfo( + id=conversation_uuid, + created_by_user_id=user_id, + sandbox_id='test_sandbox_123', + title='Original Title', + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Create mock app conversation info service + mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService) + mock_app_conversation_info_service.get_app_conversation_info = AsyncMock( + return_value=mock_app_conversation_info + ) + mock_app_conversation_info_service.save_app_conversation_info = AsyncMock( + return_value=mock_app_conversation_info + ) + + # Create mock conversation store (won't be used) + mock_conversation_store = MagicMock(spec=ConversationStore) + + # Create update request with whitespace + update_request = UpdateConversationRequest(title=title_with_whitespace) + + # Call the function + result = await update_conversation( + conversation_id=conversation_id, + data=update_request, + user_id=user_id, + conversation_store=mock_conversation_store, + app_conversation_info_service=mock_app_conversation_info_service, + ) + + # Verify the result + assert result is True + + # Verify the saved info has trimmed title + saved_info = ( + mock_app_conversation_info_service.save_app_conversation_info.call_args[0][0] + ) + assert saved_info.title == expected_title + + +@pytest.mark.update_conversation +@pytest.mark.asyncio +async def test_update_v1_conversation_invalid_uuid_falls_back_to_v0(): + """Test that invalid UUID conversation_id falls back to V0 logic.""" + conversation_id = 'not_a_valid_uuid_123' + user_id = 'test_user_456' + new_title = 'Updated Title' + + # Create mock V0 metadata + mock_metadata = ConversationMetadata( + conversation_id=conversation_id, + user_id=user_id, + title='Original Title', + selected_repository=None, + last_updated_at=datetime.now(timezone.utc), + ) + + # Create mock conversation store for V0 + mock_conversation_store = MagicMock(spec=ConversationStore) + mock_conversation_store.get_metadata = AsyncMock(return_value=mock_metadata) + mock_conversation_store.save_metadata = AsyncMock() + + # Create mock app conversation info service (won't be called) + mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService) + + # Create update request + update_request = UpdateConversationRequest(title=new_title) + + # Mock the conversation manager socket + mock_sio = AsyncMock() + + with patch( + 'openhands.server.routes.manage_conversations.conversation_manager' + ) as mock_manager: + mock_manager.sio = mock_sio + + # Call the function + result = await update_conversation( + conversation_id=conversation_id, + data=update_request, + user_id=user_id, + conversation_store=mock_conversation_store, + app_conversation_info_service=mock_app_conversation_info_service, + ) + + # Verify the result is successful + assert result is True + + # Verify V0 store was used, not V1 service + mock_conversation_store.get_metadata.assert_called_once_with(conversation_id) + mock_conversation_store.save_metadata.assert_called_once() + mock_app_conversation_info_service.get_app_conversation_info.assert_not_called() + + +@pytest.mark.update_conversation +@pytest.mark.asyncio +async def test_update_v1_conversation_no_socket_emission(): + """Test that V1 conversation update does NOT emit socket.io events.""" + conversation_uuid = uuid4() + conversation_id = str(conversation_uuid) + user_id = 'test_user_456' + new_title = 'Updated V1 Title' + + # Create mock V1 conversation info + mock_app_conversation_info = AppConversationInfo( + id=conversation_uuid, + created_by_user_id=user_id, + sandbox_id='test_sandbox_123', + title='Original Title', + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Create mock app conversation info service + mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService) + mock_app_conversation_info_service.get_app_conversation_info = AsyncMock( + return_value=mock_app_conversation_info + ) + mock_app_conversation_info_service.save_app_conversation_info = AsyncMock( + return_value=mock_app_conversation_info + ) + + # Create mock conversation store (won't be used) + mock_conversation_store = MagicMock(spec=ConversationStore) + + # Create update request + update_request = UpdateConversationRequest(title=new_title) + + # Mock the conversation manager socket + mock_sio = AsyncMock() + + with patch( + 'openhands.server.routes.manage_conversations.conversation_manager' + ) as mock_manager: + mock_manager.sio = mock_sio + + # Call the function + result = await update_conversation( + conversation_id=conversation_id, + data=update_request, + user_id=user_id, + conversation_store=mock_conversation_store, + app_conversation_info_service=mock_app_conversation_info_service, + ) + + # Verify the result is successful + assert result is True + + # Verify socket.io was NOT called for V1 conversation + mock_sio.emit.assert_not_called() + + @pytest.mark.asyncio async def test_add_message_success(): """Test successful message addition to conversation."""