Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions openhands-agent-server/openhands/agent_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
105 changes: 89 additions & 16 deletions tests/agent_server/test_conversation_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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()

Expand All @@ -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"
)
Expand Down Expand Up @@ -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()

Expand All @@ -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] = (
Expand All @@ -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()
Expand All @@ -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
):
Expand Down Expand Up @@ -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,
Expand Down
94 changes: 93 additions & 1 deletion tests/agent_server/test_conversation_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import tempfile
from datetime import UTC, datetime
from pathlib import Path
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Loading