diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py index 0da9457cc4..7e3d3f3acb 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py @@ -275,13 +275,20 @@ async def update_conversation( @conversation_router.post( "/{conversation_id}/generate_title", responses={404: {"description": "Item not found"}}, + deprecated=True, ) async def generate_conversation_title( conversation_id: UUID, request: GenerateTitleRequest, conversation_service: ConversationService = Depends(get_conversation_service), ) -> GenerateTitleResponse: - """Generate a title for the conversation using LLM.""" + """Generate a title for the conversation using LLM. + + Deprecated since v1.11.5 and scheduled for removal in v1.14.0. + + Prefer enabling `autotitle` in `StartConversationRequest` to have the server + generate and persist the title automatically from the first user message. + """ title = await conversation_service.generate_conversation_title( conversation_id, request.max_length, request.llm ) diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py index b1286a251e..eed81b0d2a 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py @@ -25,6 +25,7 @@ ConversationExecutionStatus, ConversationState, ) +from openhands.sdk.event import MessageEvent from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent from openhands.sdk.utils.cipher import Cipher @@ -505,6 +506,10 @@ async def _start_event_service(self, stored: StoredConversation) -> EventService ) # Create subscribers... await event_service.subscribe_to_events(_EventSubscriber(service=event_service)) + if stored.autotitle and stored.title is None: + await event_service.subscribe_to_events( + AutoTitleSubscriber(service=event_service) + ) asyncio.gather( *[ event_service.subscribe_to_events( @@ -548,6 +553,35 @@ async def __call__(self, _event: Event): update_last_execution_time() +@dataclass +class AutoTitleSubscriber(Subscriber): + service: EventService + + async def __call__(self, event: Event) -> None: + # Only act on incoming user messages + if not isinstance(event, MessageEvent) or event.source != "user": + return + # Guard: skip if a title was already set (e.g. by a concurrent task) + if self.service.stored.title is not None: + return + + async def _generate_and_save() -> None: + try: + title = await self.service.generate_title() + if title and self.service.stored.title is None: + self.service.stored.title = title + self.service.stored.updated_at = utc_now() + await self.service.save_meta() + except Exception: + logger.warning( + f"Auto-title generation failed for " + f"conversation {self.service.stored.id}", + exc_info=True, + ) + + asyncio.create_task(_generate_and_save()) + + @dataclass class WebhookSubscriber(Subscriber): conversation_id: UUID diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py index 4ef551e13f..debce4f8a8 100644 --- a/openhands-agent-server/openhands/agent_server/models.py +++ b/openhands-agent-server/openhands/agent_server/models.py @@ -126,6 +126,13 @@ class StartConversationRequest(BaseModel): "hooks." ), ) + autotitle: bool = Field( + default=True, + description=( + "If true, automatically generate a title for the conversation from " + "the first user message using the conversation's LLM." + ), + ) class StoredConversation(StartConversationRequest): diff --git a/tests/agent_server/test_conversation_router.py b/tests/agent_server/test_conversation_router.py index 2c747214c7..8c1bda94d5 100644 --- a/tests/agent_server/test_conversation_router.py +++ b/tests/agent_server/test_conversation_router.py @@ -1047,7 +1047,6 @@ def test_generate_conversation_title_success( ): """Test generate_conversation_title endpoint with successful generation.""" - # Mock the service response mock_conversation_service.generate_conversation_title.return_value = ( "Generated Title" ) @@ -1068,12 +1067,11 @@ def test_generate_conversation_title_success( data = response.json() assert data["title"] == "Generated Title" - # Verify service was called with correct parameters mock_conversation_service.generate_conversation_title.assert_called_once() call_args = mock_conversation_service.generate_conversation_title.call_args assert call_args[0][0] == sample_conversation_id - assert call_args[0][1] == 30 # max_length - assert call_args[0][2] is None # llm (default) + assert call_args[0][1] == 30 + assert call_args[0][2] is None finally: client.app.dependency_overrides.clear() @@ -1083,7 +1081,6 @@ def test_generate_conversation_title_with_llm( ): """Test generate_conversation_title endpoint with custom LLM.""" - # Mock the service response mock_conversation_service.generate_conversation_title.return_value = ( "Custom LLM Title" ) @@ -1111,12 +1108,11 @@ def test_generate_conversation_title_with_llm( data = response.json() assert data["title"] == "Custom LLM Title" - # Verify service was called mock_conversation_service.generate_conversation_title.assert_called_once() call_args = mock_conversation_service.generate_conversation_title.call_args assert call_args[0][0] == sample_conversation_id - assert call_args[0][1] == 40 # max_length - assert call_args[0][2] is not None # llm provided + assert call_args[0][1] == 40 + assert call_args[0][2] is not None finally: client.app.dependency_overrides.clear() @@ -1126,7 +1122,6 @@ def test_generate_conversation_title_failure( ): """Test generate_conversation_title endpoint with generation failure.""" - # Mock the service response - generation failed mock_conversation_service.generate_conversation_title.return_value = None client.app.dependency_overrides[get_conversation_service] = ( @@ -1141,9 +1136,7 @@ def test_generate_conversation_title_failure( json=request_data, ) - assert response.status_code == 500 # Internal Server Error - - # Verify service was called + assert response.status_code == 500 mock_conversation_service.generate_conversation_title.assert_called_once() finally: client.app.dependency_overrides.clear() @@ -1159,25 +1152,36 @@ def test_generate_conversation_title_invalid_params( ) try: - # Test with max_length too low request_data = {"max_length": 0} response = client.post( f"/api/conversations/{sample_conversation_id}/generate_title", json=request_data, ) - assert response.status_code == 422 # Validation error + assert response.status_code == 422 - # Test with max_length too high request_data = {"max_length": 201} response = client.post( f"/api/conversations/{sample_conversation_id}/generate_title", json=request_data, ) - assert response.status_code == 422 # Validation error + assert response.status_code == 422 finally: client.app.dependency_overrides.clear() +def test_generate_title_endpoint_is_deprecated_in_openapi(client): + response = client.get("/openapi.json") + assert response.status_code == 200 + + openapi_schema = response.json() + operation = openapi_schema["paths"][ + "/api/conversations/{conversation_id}/generate_title" + ]["post"] + + assert operation.get("deprecated") is True + assert "scheduled for removal" in operation["description"] + + def test_start_conversation_with_tool_module_qualnames( client, mock_conversation_service, sample_conversation_info ): @@ -1284,6 +1288,75 @@ def test_start_conversation_without_tool_module_qualnames( client.app.dependency_overrides.clear() +def test_start_conversation_autotitle_defaults_to_true( + client, mock_conversation_service, sample_conversation_info +): + """autotitle defaults to True when not supplied in the request.""" + mock_conversation_service.start_conversation.return_value = ( + sample_conversation_info, + True, + ) + client.app.dependency_overrides[get_conversation_service] = ( + lambda: mock_conversation_service + ) + + try: + request_data = { + "agent": { + "llm": { + "model": "gpt-4o", + "api_key": "test-key", + "usage_id": "test-llm", + }, + "tools": [{"name": "TerminalTool"}], + }, + "workspace": {"working_dir": "/tmp/test"}, + } + response = client.post("/api/conversations", json=request_data) + + assert response.status_code == 201 + call_args = mock_conversation_service.start_conversation.call_args + request_arg = call_args[0][0] + assert request_arg.autotitle is True + finally: + client.app.dependency_overrides.clear() + + +def test_start_conversation_autotitle_false( + client, mock_conversation_service, sample_conversation_info +): + """autotitle=False is forwarded correctly to the service.""" + mock_conversation_service.start_conversation.return_value = ( + sample_conversation_info, + True, + ) + client.app.dependency_overrides[get_conversation_service] = ( + lambda: mock_conversation_service + ) + + try: + request_data = { + "agent": { + "llm": { + "model": "gpt-4o", + "api_key": "test-key", + "usage_id": "test-llm", + }, + "tools": [{"name": "TerminalTool"}], + }, + "workspace": {"working_dir": "/tmp/test"}, + "autotitle": False, + } + response = client.post("/api/conversations", json=request_data) + + assert response.status_code == 201 + call_args = mock_conversation_service.start_conversation.call_args + request_arg = call_args[0][0] + assert request_arg.autotitle is False + finally: + client.app.dependency_overrides.clear() + + def test_set_conversation_security_analyzer_success( client, sample_conversation_id, diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py index e70ded17bc..c5667cc665 100644 --- a/tests/agent_server/test_conversation_service.py +++ b/tests/agent_server/test_conversation_service.py @@ -1,3 +1,4 @@ +import asyncio import tempfile from datetime import UTC, datetime from pathlib import Path @@ -8,6 +9,7 @@ from pydantic import SecretStr from openhands.agent_server.conversation_service import ( + AutoTitleSubscriber, ConversationService, ) from openhands.agent_server.event_service import EventService @@ -19,11 +21,13 @@ UpdateConversationRequest, ) from openhands.agent_server.utils import safe_rmtree as _safe_rmtree -from openhands.sdk import LLM, Agent +from openhands.sdk import LLM, Agent, Message from openhands.sdk.conversation.state import ( ConversationExecutionStatus, ConversationState, ) +from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent +from openhands.sdk.event.llm_convertible import MessageEvent from openhands.sdk.secret import SecretSource, StaticSecret from openhands.sdk.security.confirmation_policy import NeverConfirm from openhands.sdk.workspace import LocalWorkspace @@ -1404,3 +1408,91 @@ def test_safe_rmtree_readonly_file_handling(self): result = _safe_rmtree(str(test_dir), "test directory") assert result is True assert not test_dir.exists() + + +class TestAutoTitle: + """Tests for AutoTitleSubscriber.""" + + def _make_service(self, title: str | None = None) -> AsyncMock: + stored = StoredConversation( + id=uuid4(), + agent=Agent(llm=LLM(model="gpt-4o", usage_id="test-llm"), tools=[]), + workspace=LocalWorkspace(working_dir="workspace/project"), + confirmation_policy=NeverConfirm(), + initial_message=None, + metrics=None, + title=title, + ) + service = AsyncMock(spec=EventService) + service.stored = stored + return service + + def _user_message_event(self) -> MessageEvent: + return MessageEvent(id="evt-1", source="user", llm_message=Message(role="user")) + + @pytest.mark.asyncio + async def test_autotitle_sets_title_on_first_user_message(self): + """Title is generated and saved when the first user message arrives.""" + service = self._make_service() + service.generate_title.return_value = "✨ Generated Title" + + subscriber = AutoTitleSubscriber(service=service) + await subscriber(self._user_message_event()) + + # Allow the background task to complete + await asyncio.sleep(0) + + assert service.stored.title == "✨ Generated Title" + service.save_meta.assert_called_once() + + @pytest.mark.asyncio + async def test_autotitle_skips_non_user_events(self): + """Non-user events do not trigger title generation. + + Covers ConversationStateUpdateEvent and assistant MessageEvents. + """ + service = self._make_service() + subscriber = AutoTitleSubscriber(service=service) + + # ConversationStateUpdateEvent should be ignored + await subscriber( + ConversationStateUpdateEvent(key="execution_status", value="IDLE") + ) + # Assistant MessageEvent should be ignored + await subscriber( + MessageEvent( + id="evt-2", source="agent", llm_message=Message(role="assistant") + ) + ) + + await asyncio.sleep(0) + + service.generate_title.assert_not_called() + assert service.stored.title is None + + @pytest.mark.asyncio + async def test_autotitle_skips_when_title_already_set(self): + """No LLM call is made when the conversation already has a title.""" + service = self._make_service(title="Existing Title") + subscriber = AutoTitleSubscriber(service=service) + + await subscriber(self._user_message_event()) + await asyncio.sleep(0) + + service.generate_title.assert_not_called() + assert service.stored.title == "Existing Title" + + @pytest.mark.asyncio + async def test_autotitle_handles_generate_title_failure(self): + """A failed title generation is logged as a warning and not re-raised.""" + service = self._make_service() + service.generate_title.side_effect = Exception("LLM unavailable") + + subscriber = AutoTitleSubscriber(service=service) + # Should not raise + await subscriber(self._user_message_event()) + await asyncio.sleep(0) + + # Title remains unset; save_meta was never called + assert service.stored.title is None + service.save_meta.assert_not_called()