mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(backend): add support for updating the title in V1 conversations (#11446)
This commit is contained in:
parent
19634f364e
commit
f258eafa37
@ -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(
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user