diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index 38900ba7eb..df5b780e00 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -105,6 +105,14 @@ class TestAutoTitleSession: with patch("agent.title_generator.generate_title", return_value="New Title"): assert generate_title_if_missing(db, "sess-1", "hi", "hello") == "New Title" + def test_generate_title_if_missing_returns_none_on_lookup_error(self): + db = MagicMock() + db.get_session_title.side_effect = RuntimeError("db unavailable") + + with patch("agent.title_generator.generate_title") as gen: + assert generate_title_if_missing(db, "sess-1", "hi", "hello") is None + gen.assert_not_called() + def test_generates_and_sets_title(self): db = MagicMock() db.get_session_title.return_value = None @@ -121,6 +129,14 @@ class TestAutoTitleSession: auto_title_session(db, "sess-1", "hi", "hello") db.set_session_title.assert_not_called() + def test_swallows_set_title_errors(self): + db = MagicMock() + db.set_session_title.side_effect = RuntimeError("write failed") + + with patch("agent.title_generator.generate_title_if_missing", return_value="New Title"): + auto_title_session(db, "sess-1", "hi", "hello") + db.set_session_title.assert_called_once_with("sess-1", "New Title") + class TestMaybeAutoTitle: """Tests for maybe_auto_title() — the fire-and-forget entry point.""" diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index 302937726c..230c414487 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -245,6 +245,28 @@ async def test_update_thread_title_returns_false_on_api_error(): assert adapter._get_cached_thread_topic_title("111", "222") is None +def test_cache_thread_topic_title_ignores_incomplete_values(): + """Empty title/chat/thread inputs should not populate the runtime cache.""" + adapter = _make_adapter() + + adapter._cache_thread_topic_title("111", "222", "") + adapter._cache_thread_topic_title("", "222", "Title") + adapter._cache_thread_topic_title("111", "", "Title") + + assert adapter._thread_topic_titles == {} + + +@pytest.mark.asyncio +async def test_update_thread_title_returns_false_without_bot_or_title(): + """Missing bot or blank title should short-circuit before any API call.""" + adapter = _make_adapter() + assert await adapter.update_thread_title("111", "222", "Title") is False + + adapter._bot = AsyncMock() + assert await adapter.update_thread_title("111", "222", " ") is False + adapter._bot.edit_forum_topic.assert_not_called() + + @pytest.mark.asyncio async def test_forum_topic_service_handler_caches_created_and_edited_names(): """Service messages should hot-update the runtime topic-title cache.""" @@ -263,6 +285,24 @@ async def test_forum_topic_service_handler_caches_created_and_edited_names(): assert adapter._get_cached_thread_topic_title("111", "222") == "Edited Title" +@pytest.mark.asyncio +async def test_forum_topic_service_handler_ignores_missing_message_or_context(): + """Service handler should no-op when update lacks message/thread/chat context.""" + adapter = _make_adapter() + + await adapter._handle_forum_topic_service_message(SimpleNamespace(message=None), None) + await adapter._handle_forum_topic_service_message( + SimpleNamespace(message=SimpleNamespace(message_thread_id=None, chat=SimpleNamespace(id=111))), + None, + ) + await adapter._handle_forum_topic_service_message( + SimpleNamespace(message=SimpleNamespace(message_thread_id=222, chat=None)), + None, + ) + + assert adapter._thread_topic_titles == {} + + # ── _persist_dm_topic_thread_id ── @@ -596,6 +636,37 @@ def test_build_message_event_no_auto_skill_without_thread(): assert event.auto_skill is None +def test_build_message_event_prefers_cached_thread_topic_title(): + """Runtime-cached topic titles should flow into the built SessionSource.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter() + adapter._cache_thread_topic_title("111", "200", "Cached Topic") + + msg = _make_mock_message(chat_id=111, thread_id=200) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.source.chat_topic == "Cached Topic" + + +def test_build_message_event_uses_topic_created_and_edited_service_names(): + """Forum-topic service payloads should update the event topic name immediately.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter() + msg = _make_mock_message( + chat_id=111, + thread_id=300, + forum_topic_created=SimpleNamespace(name="Created Topic"), + ) + msg.forum_topic_edited = SimpleNamespace(name="Edited Topic") + + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.source.chat_topic == "Edited Topic" + assert adapter._get_cached_thread_topic_title("111", "300") == "Edited Topic" + + # ── _build_message_event: group_topics skill binding ── # The telegram mock sets sys.modules["telegram.constants"] = telegram_mod (root mock), diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 24eb6e1d3b..047c245430 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -798,6 +798,43 @@ async def test_post_delivery_callbacks_compose_for_same_session(): assert fired == ["first", "second"] +@pytest.mark.asyncio +async def test_post_delivery_callbacks_append_to_existing_list_and_preserve_unmatched_generation(): + """Generation filtering should leave unmatched callbacks queued for a later pop.""" + adapter = ProgressCaptureAdapter() + fired = [] + + adapter.register_post_delivery_callback("sess", lambda: fired.append("first"), generation=3) + adapter.register_post_delivery_callback("sess", lambda: fired.append("second"), generation=3) + adapter.register_post_delivery_callback("sess", lambda: fired.append("third"), generation=4) + + callback = adapter.pop_post_delivery_callback("sess", generation=3) + assert callable(callback) + callback() + assert fired == ["first", "second"] + + callback = adapter.pop_post_delivery_callback("sess", generation=4) + assert callable(callback) + callback() + assert fired == ["first", "second", "third"] + + +@pytest.mark.asyncio +async def test_post_delivery_callback_plain_entry_survives_generation_filtered_pop(): + """A plain callback should remain queued when popping with a generation filter.""" + adapter = ProgressCaptureAdapter() + fired = [] + + adapter.register_post_delivery_callback("sess", lambda: fired.append("plain")) + + assert adapter.pop_post_delivery_callback("sess", generation=7) is None + callback = adapter.pop_post_delivery_callback("sess") + assert callable(callback) + callback() + + assert fired == ["plain"] + + @pytest.mark.asyncio async def test_run_agent_drops_tool_progress_after_generation_invalidation(monkeypatch, tmp_path): import yaml diff --git a/tests/gateway/test_title_command.py b/tests/gateway/test_title_command.py index 37d82a1024..80c1c9c12c 100644 --- a/tests/gateway/test_title_command.py +++ b/tests/gateway/test_title_command.py @@ -220,6 +220,116 @@ class TestGatewayAutoTitleSync: adapter.update_thread_title.assert_not_called() db.close() + +class TestGatewayTitleHelpers: + @pytest.mark.asyncio + async def test_sync_session_title_to_source_returns_false_without_adapter_or_updater(self): + runner = _make_runner() + source = _make_event(thread_id="470094").source + + assert await runner._sync_session_title_to_source(source, "Title") is False + + runner.adapters[Platform.TELEGRAM] = MagicMock() + assert await runner._sync_session_title_to_source(source, "Title") is False + + @pytest.mark.asyncio + async def test_sync_session_title_to_source_handles_updater_errors(self): + runner = _make_runner() + source = _make_event(thread_id="470094").source + adapter = MagicMock() + adapter.update_thread_title = AsyncMock(side_effect=RuntimeError("boom")) + runner.adapters[Platform.TELEGRAM] = adapter + + assert await runner._sync_session_title_to_source(source, "Title") is False + + @pytest.mark.asyncio + async def test_schedule_session_title_sync_after_delivery_falls_back_to_adapter_dict(self): + runner = _make_runner() + source = _make_event(thread_id="470094").source + adapter = MagicMock() + adapter._post_delivery_callbacks = {} + del adapter.register_post_delivery_callback + adapter.update_thread_title = AsyncMock(return_value=True) + runner.adapters[Platform.TELEGRAM] = adapter + + scheduled = runner._schedule_session_title_sync_after_delivery( + session_key="telegram:12345:67890", + source=source, + title="Title", + ) + + assert scheduled is True + callback = adapter._post_delivery_callbacks["telegram:12345:67890"] + callback() + await asyncio.sleep(0) + adapter.update_thread_title.assert_awaited_once_with("67890", "470094", "Title") + + @pytest.mark.asyncio + async def test_apply_session_title_uses_only_if_missing_path(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("test_session_123", "telegram") + + runner = _make_runner(session_db=db) + source = _make_event(thread_id="470094").source + + changed = await runner._apply_session_title( + session_id="test_session_123", + source=source, + title="Initial", + only_if_missing=True, + ) + unchanged = await runner._apply_session_title( + session_id="test_session_123", + source=source, + title="Second", + only_if_missing=True, + ) + + assert changed is True + assert unchanged is False + assert db.get_session_title("test_session_123") == "Initial" + db.close() + + @pytest.mark.asyncio + async def test_maybe_schedule_gateway_auto_title_registers_post_delivery_callback(self): + runner = _make_runner(session_db=MagicMock()) + source = _make_event(thread_id="470094").source + adapter = MagicMock() + runner.adapters[Platform.TELEGRAM] = adapter + + with patch("agent.title_generator.should_auto_title", return_value=True): + runner._maybe_schedule_gateway_auto_title( + session_id="test_session_123", + session_key="telegram:12345:67890", + source=source, + user_message="hello", + assistant_response="hi", + conversation_history=[{"role": "user", "content": "hello"}], + generation=5, + ) + + adapter.register_post_delivery_callback.assert_called_once() + + @pytest.mark.asyncio + async def test_maybe_schedule_gateway_auto_title_launches_immediately_without_adapter_registration(self): + runner = _make_runner(session_db=MagicMock()) + source = _make_event(thread_id="470094").source + + with patch("agent.title_generator.should_auto_title", return_value=True), \ + patch.object(runner, "_auto_title_gateway_session", AsyncMock()) as auto_title: + runner._maybe_schedule_gateway_auto_title( + session_id="test_session_123", + session_key=None, + source=source, + user_message="hello", + assistant_response="hi", + conversation_history=[{"role": "user", "content": "hello"}], + ) + await asyncio.sleep(0) + + auto_title.assert_awaited_once() + @pytest.mark.asyncio async def test_show_title_when_set(self, tmp_path): """Showing title when one is set returns the title.""" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 508db86d16..6676fe0599 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -974,6 +974,12 @@ class TestSessionTitle: with pytest.raises(ValueError, match="already in use"): db.set_session_title_if_missing("s2", "Taken") + def test_set_title_if_missing_rejects_empty_title(self, db): + db.create_session(session_id="s1", source="cli") + + assert db.set_session_title_if_missing("s1", " ") is False + assert db.get_session("s1")["title"] is None + def test_title_in_search_sessions(self, db): db.create_session(session_id="s1", source="cli") db.set_session_title("s1", "Debugging Auth")