diff --git a/agent/title_generator.py b/agent/title_generator.py index 3f617093c0b6..a543bfa357e1 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -18,6 +18,11 @@ # become visible instead of piling up as NULL session titles. FailureCallback = Callable[[str, BaseException], None] +# Callback signature: (title) -> None. Fired after a generated title is +# successfully persisted to the session DB so platform adapters (e.g. +# Telegram forum topics) can sync the title to their UI. Issue #16255. +TitleSetCallback = Callable[[str], None] + _TITLE_PROMPT = ( "Generate a short, descriptive title (3-7 words) for a conversation that starts with the " "following exchange. The title should capture the main topic or intent. " @@ -90,6 +95,7 @@ def auto_title_session( assistant_response: str, failure_callback: Optional[FailureCallback] = None, main_runtime: dict = None, + on_title_set: Optional[TitleSetCallback] = None, ) -> None: """Generate and set a session title if one doesn't already exist. @@ -98,6 +104,11 @@ def auto_title_session( - session_db is None - session already has a title (user-set or previously auto-generated) - title generation fails + + When *on_title_set* is provided and a title is successfully persisted, + it is invoked with the new title. Used by platform adapters to push + the title to e.g. a Telegram forum topic name. Exceptions raised by + the callback are swallowed — title sync is best-effort UX. """ if not session_db or not session_id: return @@ -121,6 +132,13 @@ def auto_title_session( logger.debug("Auto-generated session title: %s", title) except Exception as e: logger.debug("Failed to set auto-generated title: %s", e) + return + + if on_title_set is not None: + try: + on_title_set(title) + except Exception: + logger.debug("on_title_set callback raised", exc_info=True) def maybe_auto_title( @@ -131,6 +149,7 @@ def maybe_auto_title( conversation_history: list, failure_callback: Optional[FailureCallback] = None, main_runtime: dict = None, + on_title_set: Optional[TitleSetCallback] = None, ) -> None: """Fire-and-forget title generation after the first exchange. @@ -152,7 +171,11 @@ def maybe_auto_title( thread = threading.Thread( target=auto_title_session, args=(session_db, session_id, user_message, assistant_response), - kwargs={"failure_callback": failure_callback, "main_runtime": main_runtime}, + kwargs={ + "failure_callback": failure_callback, + "main_runtime": main_runtime, + "on_title_set": on_title_set, + }, daemon=True, name="auto-title", ) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index a06b6fa71105..306c4fedb648 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1431,6 +1431,24 @@ async def stop_typing(self, chat_id: str) -> None: Default is a no-op for platforms with one-shot typing indicators. """ pass + + async def update_topic_title( + self, + chat_id: str, + thread_id: Optional[str], + title: str, + ) -> None: + """Update a forum / topic / thread title on the platform. + + Default no-op. Platforms that expose per-thread titles (Telegram + forum topics, Discord threads, …) should override this and push the + new ``title`` to the platform so the topic list stays in sync with + the auto-generated session title. + + Implementations SHOULD swallow and log their own errors — title + sync is a best-effort UX nicety, never load-bearing. + """ + return None async def send_image( self, diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 09a70ccf51fe..e6a7316bf478 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -591,6 +591,77 @@ async def _create_dm_topic( ) return None + # Telegram limits forum topic names to 128 characters (Bot API 6.4+). + _FORUM_TOPIC_NAME_MAX = 128 + + async def update_topic_title( + self, + chat_id: str, + thread_id: Optional[str], + title: str, + ) -> None: + """Push *title* to the Telegram forum topic at ``chat_id``/``thread_id``. + + No-ops when the bot isn't connected, when ``thread_id`` is missing + or refers to the implicit "General" topic, or when ``title`` is + empty. Errors are logged at DEBUG and never propagate — title + sync is best-effort UX, not load-bearing. + + Issue #16255. + """ + if not self._bot or not thread_id: + return + if str(thread_id) == self._GENERAL_TOPIC_THREAD_ID: + # The General topic name is owned by the chat itself, not the + # bot — editForumTopic only applies to bot-created topics. + return + clean = (title or "").strip() + if not clean: + return + if len(clean) > self._FORUM_TOPIC_NAME_MAX: + clean = clean[: self._FORUM_TOPIC_NAME_MAX] + + try: + tid = int(thread_id) + except (TypeError, ValueError): + logger.debug( + "[%s] update_topic_title: non-numeric thread_id %r", self.name, thread_id, + ) + return + + try: + cid = int(chat_id) + except (TypeError, ValueError): + cid = chat_id # Telegram accepts @channel-style ids too + + try: + await self._bot.edit_forum_topic( + chat_id=cid, + message_thread_id=tid, + name=clean, + ) + except Exception as e: + logger.debug( + "[%s] Failed to edit forum topic %s/%s: %s", + self.name, cid, tid, e, + ) + return + + # Keep the cache aligned so /resume-style lookups by name find the + # renamed topic. Walk the dict because we don't always know the + # old name here (auto-titles update an existing topic). + old_name = None + for cached_name, cached_tid in list(self._dm_topics.items()): + if cached_tid == tid: + old_name = cached_name + break + if old_name is not None and old_name != clean: + self._dm_topics.pop(old_name, None) + self._dm_topics[clean] = tid + elif old_name is None: + # Not previously cached (e.g. created by another process); add it. + self._dm_topics[clean] = tid + def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None: """Save a newly created thread_id back into config.yaml so it persists across restarts.""" try: diff --git a/gateway/run.py b/gateway/run.py index a37f72b5ec8c..6ebc08a8be39 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7706,6 +7706,65 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: logger.warning("Manual compress failed: %s", e) return f"Compression failed: {e}" + async def _sync_topic_title_to_platform( + self, + source: SessionSource, + title: str, + ) -> None: + """Push *title* to the source platform's per-topic UI when supported. + + For Telegram forum topics this calls ``editForumTopic`` so the topic + list shows the same title that appears in ``/history``. Other + platforms inherit the no-op base implementation. Errors are logged + and swallowed — best-effort UX, never load-bearing. Issue #16255. + """ + if not source or not source.platform or not source.thread_id: + return + adapter = self.adapters.get(source.platform) + if adapter is None: + return + try: + await adapter.update_topic_title( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + title=title, + ) + except Exception as exc: + logger.debug( + "update_topic_title failed for %s/%s: %s", + source.platform.value if source.platform else "?", + source.chat_id, exc, + ) + + def _make_topic_title_sync_callback( + self, + source: SessionSource, + loop: "asyncio.AbstractEventLoop", + ): + """Return an ``on_title_set`` callback that pushes a freshly-generated + title to the source platform's topic UI from a background thread. + + ``maybe_auto_title`` runs in a daemon thread, so we hop back to the + gateway event loop via ``run_coroutine_threadsafe``. Returns + ``None`` when the source has no thread_id (nothing to update) so + ``maybe_auto_title`` can skip the indirection. + """ + if not source or not source.platform or not source.thread_id: + return None + if loop is None: + return None + + def _sync(title: str) -> None: + try: + asyncio.run_coroutine_threadsafe( + self._sync_topic_title_to_platform(source, title), + loop, + ) + except Exception: + logger.debug("topic title sync schedule failed", exc_info=True) + + return _sync + async def _handle_title_command(self, event: MessageEvent) -> str: """Handle /title command — set or show the current session's title.""" source = event.source @@ -7741,6 +7800,9 @@ async def _handle_title_command(self, event: MessageEvent) -> str: # Set the title try: if self._session_db.set_session_title(session_id, sanitized): + # Push the new title to the platform's topic UI + # (Telegram forum topic name, etc.). Issue #16255. + await self._sync_topic_title_to_platform(source, sanitized) return f"✏️ Session title set: **{sanitized}**" else: return "Session not found in database." @@ -11083,6 +11145,12 @@ def _approval_notify_sync(approval_data: dict) -> None: _title_failure_cb = getattr( agent, "_emit_auxiliary_failure", None ) + # Push the new title to the platform's topic UI + # (Telegram forum topic name, etc.) so the topic list + # stays in sync with what /history shows. Issue #16255. + _title_set_cb = self._make_topic_title_sync_callback( + source, _loop_for_step, + ) maybe_auto_title( self._session_db, effective_session_id, @@ -11097,6 +11165,7 @@ def _approval_notify_sync(approval_data: dict) -> None: "api_key": getattr(agent, "api_key", None), "api_mode": getattr(agent, "api_mode", None), } if agent else None, + on_title_set=_title_set_cb, ) except Exception: pass diff --git a/scripts/release.py b/scripts/release.py index d66b3b36d43c..ffd69117a1cc 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -570,6 +570,7 @@ "shamork@outlook.com": "shamork", # April 2026 Discord Copilot /model salvage (#15030) "cshong2017@outlook.com": "Nicecsh", + "sky.cool.ezreal@gmail.com": "cola-runner", # no-github-match — keep as display names "clio-agent@sisyphuslabs.ai": "Sisyphus", "marco@rutimka.de": "Marco Rutsch", diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index e10cba76a89a..53655b92a104 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -182,7 +182,8 @@ def test_fires_on_first_exchange(self): import time time.sleep(0.3) mock_auto.assert_called_once_with( - db, "sess-1", "hello", "hi there", failure_callback=None, main_runtime=None + db, "sess-1", "hello", "hi there", + failure_callback=None, main_runtime=None, on_title_set=None, ) def test_forwards_failure_callback_to_worker(self): @@ -202,7 +203,8 @@ def _cb(task, exc): import time time.sleep(0.3) mock_auto.assert_called_once_with( - db, "sess-1", "hello", "hi there", failure_callback=_cb, main_runtime=None + db, "sess-1", "hello", "hi there", + failure_callback=_cb, main_runtime=None, on_title_set=None, ) def test_skips_if_no_response(self): @@ -211,3 +213,77 @@ def test_skips_if_no_response(self): def test_skips_if_no_session_db(self): maybe_auto_title(None, "sess-1", "hello", "response", []) # no db + + +class TestOnTitleSetCallback: + """``on_title_set`` callback fires after the title is persisted (#16255).""" + + def test_callback_invoked_with_new_title(self): + db = MagicMock() + db.get_session_title.return_value = None + captured = [] + + with patch("agent.title_generator.generate_title", return_value="Fresh Title"): + auto_title_session( + db, "sess-1", "hi", "hello", + on_title_set=lambda t: captured.append(t), + ) + + assert captured == ["Fresh Title"] + db.set_session_title.assert_called_once_with("sess-1", "Fresh Title") + + def test_callback_skipped_when_title_already_exists(self): + db = MagicMock() + db.get_session_title.return_value = "Pre-existing" + captured = [] + + with patch("agent.title_generator.generate_title", return_value="ignored"): + auto_title_session( + db, "sess-1", "hi", "hello", + on_title_set=lambda t: captured.append(t), + ) + + assert captured == [] + + def test_callback_skipped_when_generation_returns_none(self): + db = MagicMock() + db.get_session_title.return_value = None + captured = [] + + with patch("agent.title_generator.generate_title", return_value=None): + auto_title_session( + db, "sess-1", "hi", "hello", + on_title_set=lambda t: captured.append(t), + ) + + assert captured == [] + db.set_session_title.assert_not_called() + + def test_callback_skipped_when_persist_fails(self): + """If set_session_title raises, the platform sync must not fire either.""" + db = MagicMock() + db.get_session_title.return_value = None + db.set_session_title.side_effect = RuntimeError("disk full") + captured = [] + + with patch("agent.title_generator.generate_title", return_value="x"): + auto_title_session( + db, "sess-1", "hi", "hello", + on_title_set=lambda t: captured.append(t), + ) + + assert captured == [] + + def test_callback_exceptions_are_swallowed(self): + """Title sync is best-effort; a raising callback must not crash the worker.""" + db = MagicMock() + db.get_session_title.return_value = None + + def _bad(_title): + raise RuntimeError("editForumTopic 400") + + with patch("agent.title_generator.generate_title", return_value="x"): + # Should not propagate. + auto_title_session(db, "sess-1", "hi", "hello", on_title_set=_bad) + + db.set_session_title.assert_called_once() diff --git a/tests/gateway/test_telegram_topic_title.py b/tests/gateway/test_telegram_topic_title.py new file mode 100644 index 000000000000..b06bf0eb9b2d --- /dev/null +++ b/tests/gateway/test_telegram_topic_title.py @@ -0,0 +1,152 @@ +"""Tests for ``TelegramAdapter.update_topic_title`` — issue #16255. + +Auto-generated session titles must propagate to the corresponding Telegram +forum topic name so the topic list and ``/history`` stay in sync. +""" + +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import PlatformConfig + + +# --------------------------------------------------------------------------- +# Mock the python-telegram-bot package when not installed +# --------------------------------------------------------------------------- + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + mod = MagicMock() + mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + mod.constants.ChatType.GROUP = "group" + mod.constants.ChatType.SUPERGROUP = "supergroup" + mod.constants.ChatType.CHANNEL = "channel" + mod.constants.ChatType.PRIVATE = "private" + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, mod) + + +_ensure_telegram_mock() + + +from gateway.platforms.telegram import TelegramAdapter # noqa: E402 + + +@pytest.fixture() +def adapter(): + cfg = PlatformConfig(enabled=True, token="fake-token") + a = TelegramAdapter(cfg) + a._bot = MagicMock() + a._bot.edit_forum_topic = AsyncMock() + return a + + +# --------------------------------------------------------------------------- +# update_topic_title +# --------------------------------------------------------------------------- + + +class TestUpdateTopicTitle: + @pytest.mark.asyncio + async def test_pushes_title_to_bot(self, adapter): + await adapter.update_topic_title(chat_id="123", thread_id="42", title="Fresh Title") + adapter._bot.edit_forum_topic.assert_awaited_once_with( + chat_id=123, message_thread_id=42, name="Fresh Title", + ) + + @pytest.mark.asyncio + async def test_no_op_without_thread_id(self, adapter): + await adapter.update_topic_title(chat_id="123", thread_id=None, title="X") + adapter._bot.edit_forum_topic.assert_not_called() + + @pytest.mark.asyncio + async def test_no_op_for_general_topic(self, adapter): + # The "General" topic name is owned by the chat itself, not the bot. + await adapter.update_topic_title( + chat_id="123", + thread_id=TelegramAdapter._GENERAL_TOPIC_THREAD_ID, + title="X", + ) + adapter._bot.edit_forum_topic.assert_not_called() + + @pytest.mark.asyncio + async def test_no_op_when_bot_missing(self, adapter): + adapter._bot = None + await adapter.update_topic_title(chat_id="123", thread_id="42", title="X") + # No crash; nothing to assert beyond that. + + @pytest.mark.asyncio + async def test_no_op_for_blank_title(self, adapter): + for blank in ("", " ", "\t"): + await adapter.update_topic_title(chat_id="123", thread_id="42", title=blank) + adapter._bot.edit_forum_topic.assert_not_called() + + @pytest.mark.asyncio + async def test_truncates_to_telegram_limit(self, adapter): + # Telegram caps forum topic names at 128 chars. + long_title = "A" * 200 + await adapter.update_topic_title(chat_id="123", thread_id="42", title=long_title) + kwargs = adapter._bot.edit_forum_topic.await_args.kwargs + assert len(kwargs["name"]) == 128 + assert kwargs["name"] == "A" * 128 + + @pytest.mark.asyncio + async def test_strips_whitespace(self, adapter): + await adapter.update_topic_title(chat_id="123", thread_id="42", title=" spaced ") + kwargs = adapter._bot.edit_forum_topic.await_args.kwargs + assert kwargs["name"] == "spaced" + + @pytest.mark.asyncio + async def test_non_numeric_thread_id_skipped(self, adapter): + await adapter.update_topic_title(chat_id="123", thread_id="not-a-number", title="X") + adapter._bot.edit_forum_topic.assert_not_called() + + @pytest.mark.asyncio + async def test_bot_failure_is_swallowed(self, adapter): + adapter._bot.edit_forum_topic.side_effect = RuntimeError("400 Bad Request") + # Must not propagate — title sync is best-effort UX. + await adapter.update_topic_title(chat_id="123", thread_id="42", title="X") + + @pytest.mark.asyncio + async def test_cache_realigned_after_rename(self, adapter): + adapter._dm_topics = {"Old Name": 42, "Other": 7} + await adapter.update_topic_title(chat_id="123", thread_id="42", title="New Name") + assert "Old Name" not in adapter._dm_topics + assert adapter._dm_topics["New Name"] == 42 + assert adapter._dm_topics["Other"] == 7 + + @pytest.mark.asyncio + async def test_cache_filled_when_thread_unknown(self, adapter): + adapter._dm_topics = {} + await adapter.update_topic_title(chat_id="123", thread_id="42", title="Brand New") + assert adapter._dm_topics == {"Brand New": 42} + + @pytest.mark.asyncio + async def test_cache_unchanged_when_name_unchanged(self, adapter): + adapter._dm_topics = {"Same": 42} + await adapter.update_topic_title(chat_id="123", thread_id="42", title="Same") + assert adapter._dm_topics == {"Same": 42} + + +# --------------------------------------------------------------------------- +# Base class default — every other adapter must inherit a no-op +# --------------------------------------------------------------------------- + + +class TestBaseDefaultIsNoOp: + @pytest.mark.asyncio + async def test_base_default_returns_none(self): + """Call the base method directly — it must remain a no-op default + so non-Telegram adapters don't have to implement anything.""" + from gateway.platforms.base import BasePlatformAdapter + + # Bind the unbound method to None to bypass abstract-class checks. + result = await BasePlatformAdapter.update_topic_title( + None, chat_id="x", thread_id="1", title="anything", + ) + assert result is None