"""Tests for agent.title_generator — auto-generated session titles.""" import threading from unittest.mock import MagicMock, patch import pytest from agent.title_generator import ( generate_title, auto_title_session, maybe_auto_title, ) class TestGenerateTitle: """Unit tests for generate_title().""" def test_returns_title_on_success(self): mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message.content = "Debugging Python Import Errors" with patch("agent.title_generator.call_llm", return_value=mock_response): title = generate_title("help me fix this import", "Sure, let me check...") assert title == "Debugging Python Import Errors" def test_strips_quotes(self): mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message.content = '"Setting Up Docker Environment"' with patch("agent.title_generator.call_llm", return_value=mock_response): title = generate_title("how do I set up docker", "First install...") assert title == "Setting Up Docker Environment" def test_strips_title_prefix(self): mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message.content = "Title: Kubernetes Pod Debugging" with patch("agent.title_generator.call_llm", return_value=mock_response): title = generate_title("my pod keeps crashing", "Let me look...") assert title == "Kubernetes Pod Debugging" def test_truncates_long_titles(self): mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message.content = "A" * 100 with patch("agent.title_generator.call_llm", return_value=mock_response): title = generate_title("question", "answer") assert len(title) == 80 assert title.endswith("...") def test_returns_none_on_empty_response(self): mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message.content = "" with patch("agent.title_generator.call_llm", return_value=mock_response): assert generate_title("question", "answer") is None def test_returns_none_on_exception(self): with patch("agent.title_generator.call_llm", side_effect=RuntimeError("no provider")): assert generate_title("question", "answer") is None def test_invokes_failure_callback_on_exception(self): """failure_callback must fire so the user sees a warning (issue #15775).""" captured = [] def _cb(task, exc): captured.append((task, exc)) exc = RuntimeError("openrouter 402: credits exhausted") with patch("agent.title_generator.call_llm", side_effect=exc): result = generate_title("question", "answer", failure_callback=_cb) assert result is None assert len(captured) == 1 assert captured[0][0] == "title generation" assert captured[0][1] is exc def test_failure_callback_errors_are_swallowed(self): """A broken callback must not crash title generation.""" def _bad_cb(task, exc): raise ValueError("callback bug") with patch("agent.title_generator.call_llm", side_effect=RuntimeError("nope")): # Should return None without re-raising the callback error assert generate_title("q", "a", failure_callback=_bad_cb) is None def test_no_callback_matches_legacy_behavior(self): """Omitting failure_callback preserves the silent-None return.""" with patch("agent.title_generator.call_llm", side_effect=RuntimeError("nope")): assert generate_title("q", "a") is None def test_truncates_long_messages(self): """Long user/assistant messages should be truncated in the LLM request.""" captured_kwargs = {} def mock_call_llm(**kwargs): captured_kwargs.update(kwargs) resp = MagicMock() resp.choices = [MagicMock()] resp.choices[0].message.content = "Short Title" return resp with patch("agent.title_generator.call_llm", side_effect=mock_call_llm): generate_title("x" * 1000, "y" * 1000) # The user content in the messages should be truncated user_content = captured_kwargs["messages"][1]["content"] assert len(user_content) < 1100 # 500 + 500 + formatting class TestAutoTitleSession: """Tests for auto_title_session() — the sync worker function.""" def test_skips_if_no_session_db(self): auto_title_session(None, "sess-1", "hi", "hello") # should not crash def test_skips_if_title_exists(self): db = MagicMock() db.get_session_title.return_value = "Existing Title" with patch("agent.title_generator.generate_title") as gen: auto_title_session(db, "sess-1", "hi", "hello") gen.assert_not_called() def test_generates_and_sets_title(self): db = MagicMock() db.get_session_title.return_value = None with patch("agent.title_generator.generate_title", return_value="New Title"): auto_title_session(db, "sess-1", "hi", "hello") db.set_session_title.assert_called_once_with("sess-1", "New Title") def test_skips_if_generation_fails(self): db = MagicMock() db.get_session_title.return_value = None with patch("agent.title_generator.generate_title", return_value=None): auto_title_session(db, "sess-1", "hi", "hello") db.set_session_title.assert_not_called() class TestMaybeAutoTitle: """Tests for maybe_auto_title() — the fire-and-forget entry point.""" def test_skips_if_not_first_exchange(self): """Should not fire for conversations with more than 2 user messages.""" db = MagicMock() history = [ {"role": "user", "content": "first"}, {"role": "assistant", "content": "response 1"}, {"role": "user", "content": "second"}, {"role": "assistant", "content": "response 2"}, {"role": "user", "content": "third"}, {"role": "assistant", "content": "response 3"}, ] with patch("agent.title_generator.auto_title_session") as mock_auto: maybe_auto_title(db, "sess-1", "third", "response 3", history) # Wait briefly for any thread to start import time time.sleep(0.1) mock_auto.assert_not_called() def test_fires_on_first_exchange(self): """Should fire a background thread for the first exchange.""" db = MagicMock() db.get_session_title.return_value = None history = [ {"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi there"}, ] with patch("agent.title_generator.auto_title_session") as mock_auto: maybe_auto_title(db, "sess-1", "hello", "hi there", history) # Wait for the daemon thread to complete import time time.sleep(0.3) mock_auto.assert_called_once_with( db, "sess-1", "hello", "hi there", failure_callback=None, main_runtime=None ) def test_forwards_failure_callback_to_worker(self): """maybe_auto_title must forward failure_callback into the thread.""" db = MagicMock() db.get_session_title.return_value = None history = [ {"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi there"}, ] def _cb(task, exc): pass with patch("agent.title_generator.auto_title_session") as mock_auto: maybe_auto_title(db, "sess-1", "hello", "hi there", history, failure_callback=_cb) import time time.sleep(0.3) mock_auto.assert_called_once_with( db, "sess-1", "hello", "hi there", failure_callback=_cb, main_runtime=None ) def test_skips_if_no_response(self): db = MagicMock() maybe_auto_title(db, "sess-1", "hello", "", []) # empty response def test_skips_if_no_session_db(self): maybe_auto_title(None, "sess-1", "hello", "response", []) # no db