Skip to content
Closed
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
25 changes: 24 additions & 1 deletion agent/title_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. "
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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.

Expand All @@ -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",
)
Expand Down
18 changes: 18 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
69 changes: 69 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 78 additions & 2 deletions tests/agent/test_title_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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()
Loading
Loading