feat: add manual session title regeneration (#3223, @AJV20)

Closes #3106. Adds /api/session/title/regenerate endpoint + session-action
menu item. Preserves chronology (touch_updated_at=False), guards read-only
and imported sessions, syncs to state.db when Insights sync enabled.

Maintainer refinement (Opus SHOULD-FIX): scope the is_imported guard to the
regenerate action only instead of broadening the shared _isReadOnlySession()
helper, which also gates rename/pin/archive/move/fork. Matches the backend
403 guard. Test updated to assert the scoped shape.
This commit is contained in:
nesquena-hermes 2026-06-01 21:22:16 +00:00
parent 949dc7aac8
commit 546afe8374
7 changed files with 304 additions and 2 deletions

View file

@ -5,6 +5,7 @@
### Added
- Generated local image artifacts now render as compact chat cards with an inline preview, the filename, and localized **Open** / **Download** actions, all served through authenticated `/api/media` URLs (#3220, @AJV20).
- The session action menu can regenerate conversation titles on demand from the saved transcript, updating the sidebar without touching conversation chronology and syncing the new title through to state.db when Insights sync is enabled. Closes #3106 (#3223, @AJV20).
## [v0.51.204] — 2026-06-01 — Release FX (stage-batch17 — project/session operations honor the session's own profile)
@ -124,6 +125,7 @@
- Agent self-update no longer advertises or applies unreachable release tags when the checkout tracks `main` past an older tag but the newest published tag lives on a divergent side branch (for example `v2026.5.29` → `v2026.5.29.2`). The update checker and apply path now fall through to the configured upstream branch when `git pull --ff-only <latest-tag>` cannot fast-forward, matching the existing #2653/#3140 release-vs-branch routing (#3257, @pamnard).
- Added regression coverage pinning `_run_git()`'s UTF-8 decoding (`encoding='utf-8'`, `errors='replace'`) and its defensive `None`-stdout guard, so version detection cannot crash on non-UTF-8 Windows console output (#3254, @zapabob).
## [v0.51.185] — 2026-05-31 — Release FE (stage-batchE — clarify-card bug-fix batch: identical-prompt dedup + autofill guard + GBK startup crash)
### Fixed

View file

@ -2846,6 +2846,7 @@ from api.streaming import (
_run_agent_streaming,
cancel_stream,
_materialize_pending_user_turn_before_error,
generate_session_title_for_session,
)
from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled
from api.run_journal import (
@ -5738,6 +5739,27 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/sessions/cleanup_zero_message":
return _handle_sessions_cleanup(handler, body, zero_only=True)
def _sync_session_title_to_insights(session):
"""Write title-only session metadata updates through to state.db when enabled."""
try:
if not load_settings().get("sync_to_insights"):
return
from api.state_sync import sync_session_usage
messages = getattr(session, "messages", None) or []
sync_session_usage(
session_id=session.session_id,
input_tokens=getattr(session, "input_tokens", None) or 0,
output_tokens=getattr(session, "output_tokens", None) or 0,
estimated_cost=getattr(session, "estimated_cost", 0.0),
model=getattr(session, "model", ""),
title=session.title,
message_count=len(messages),
profile=getattr(session, "profile", None),
)
except Exception:
logger.debug("Failed to update session title in state.db", exc_info=True)
if parsed.path == "/api/session/rename":
try:
require(body, "session_id", "title")
@ -5754,6 +5776,37 @@ def handle_post(handler, parsed) -> bool:
publish_session_list_changed("session_rename")
return j(handler, {"session": s.compact()})
if parsed.path == "/api/session/title/regenerate":
try:
require(body, "session_id")
except ValueError as e:
return bad(handler, str(e))
sid = body["session_id"]
prefer_latest = bool(body.get("prefer_latest", False))
try:
s = get_session(sid)
s = _ensure_full_session_before_mutation(sid, s)
except KeyError:
return bad(handler, "Session not found", 404)
if getattr(s, "read_only", False) or getattr(s, "is_imported", False):
return bad(handler, "Read-only imported sessions cannot be renamed", 403)
next_title, reason, raw_preview = generate_session_title_for_session(s, prefer_latest=prefer_latest)
if not next_title:
return bad(handler, f"Could not generate a better title ({reason or 'empty'})", 422)
with _get_session_agent_lock(sid):
s.title = str(next_title).strip()[:80] or "Untitled"
s.llm_title_generated = True
s.save(touch_updated_at=False)
_sync_session_title_to_insights(s)
publish_session_list_changed("session_title_regenerate")
return j(handler, {
"session": s.compact(),
"title": s.title,
"status": reason,
"raw_preview": (raw_preview or "")[:240],
})
if parsed.path == "/api/personality/set":
try:
require(body, "session_id")

View file

@ -2263,6 +2263,35 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex
logger.debug("Background title refresh failed for session %s", session_id, exc_info=True)
def generate_session_title_for_session(session, *, prefer_latest: bool = False, agent=None) -> tuple[Optional[str], str, str]:
"""Generate a session title on demand from persisted conversation messages.
This helper powers explicit UI title-regeneration controls. It intentionally
does not inspect or mutate ``llm_title_generated``; callers decide whether
replacing the current title is allowed, then persist the returned title.
"""
messages = getattr(session, 'messages', None) or []
if prefer_latest:
user_text, assistant_text = _latest_exchange_snippets(messages)
else:
user_text, assistant_text = _first_exchange_snippets(messages)
if not user_text:
return None, 'empty_user_message', ''
from api import profiles as profiles_api
with profiles_api.profile_env_for_background_worker(session, "manual title regeneration", logger_override=logger):
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent)
if next_title:
return next_title, llm_status, raw_preview
fallback_title = _fallback_title_from_exchange(user_text, assistant_text)
if fallback_title and not _is_generic_fallback_title(fallback_title):
reason = f'local_summary:{llm_status}' if llm_status else 'local_summary'
return fallback_title, reason, raw_preview
return None, llm_status or 'empty_title', raw_preview
def _preserve_pre_compression_snapshot(s, old_sid: str) -> None:
"""Persist old_sid as a read-only pre-compression snapshot.

View file

@ -427,6 +427,11 @@ const LOCALES = {
path_copy_failed: 'Failed to copy path: ',
session_rename: 'Rename conversation',
session_rename_desc: 'Edit the title of this conversation',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: 'Copy conversation link',
session_copy_link_desc: 'Copy a direct link to this conversation',
session_link_copied: 'Conversation link copied to clipboard',
@ -1749,6 +1754,11 @@ const LOCALES = {
path_copy_failed: 'Copia percorso fallita: ',
session_rename: 'Rinomina conversazione',
session_rename_desc: 'Modifica il titolo di questa conversazione',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: 'Copia link conversazione',
session_copy_link_desc: 'Copia un link diretto a questa conversazione',
session_link_copied: 'Link conversazione copiato negli appunti',
@ -3063,6 +3073,11 @@ const LOCALES = {
path_copy_failed: 'パスのコピーに失敗しました: ',
session_rename: '会話の名前を変更',
session_rename_desc: 'この会話のタイトルを編集',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: '会話リンクをコピー',
session_copy_link_desc: 'この会話への直接リンクをコピー',
session_link_copied: '会話リンクをクリップボードにコピーしました',
@ -4302,6 +4317,11 @@ const LOCALES = {
path_copy_failed: 'Не удалось скопировать путь: ',
session_rename: 'Переименовать беседу',
session_rename_desc: 'Изменить название этой беседы',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: 'Скопировать ссылку на беседу',
session_copy_link_desc: 'Скопировать прямую ссылку на эту беседу',
session_link_copied: 'Ссылка на беседу скопирована в буфер обмена',
@ -5533,6 +5553,11 @@ const LOCALES = {
path_copy_failed: 'Error al copiar la ruta: ',
session_rename: 'Renombrar conversación',
session_rename_desc: 'Editar el título de esta conversación',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: 'Copiar enlace de conversación',
session_copy_link_desc: 'Copia un enlace directo a esta conversación',
session_link_copied: 'Enlace de conversación copiado al portapapeles',
@ -6768,6 +6793,11 @@ const LOCALES = {
path_copy_failed: 'Pfad konnte nicht kopiert werden: ',
session_rename: 'Unterhaltung umbenennen',
session_rename_desc: 'Titel dieser Unterhaltung bearbeiten',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: 'Unterhaltungslink kopieren',
session_copy_link_desc: 'Direkten Link zu dieser Unterhaltung kopieren',
session_link_copied: 'Unterhaltungslink in die Zwischenablage kopiert',
@ -8055,6 +8085,11 @@ const LOCALES = {
path_copy_failed: '\u590d\u5236\u8def\u5f84\u5931\u8d25\uff1a',
session_rename: '\u91cd\u547d\u540d\u5bf9\u8bdd',
session_rename_desc: '\u7f16\u8f91\u6b64\u5bf9\u8bdd\u7684\u6807\u9898',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: '复制对话链接',
session_copy_link_desc: '复制此对话的直接链接',
session_link_copied: '对话链接已复制到剪贴板',
@ -9205,6 +9240,11 @@ const LOCALES = {
path_copy_failed: '\u8907\u88fd\u8def\u5f91\u5931\u6557\uff1a',
session_rename: '\u91cd\u65b0\u547d\u540d\u5c0d\u8a71',
session_rename_desc: '\u7de8\u8f2f\u6b64\u5c0d\u8a71\u7684\u6a19\u984c',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: '複製對話連結',
session_copy_link_desc: '複製此對話的直接連結',
session_link_copied: '對話連結已複製到剪貼簿',
@ -10598,6 +10638,11 @@ const LOCALES = {
path_copy_failed: 'Falha ao copiar caminho: ',
session_rename: 'Renomear conversa',
session_rename_desc: 'Editar o título desta conversa',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: 'Copiar link da conversa',
session_copy_link_desc: 'Copia um link direto para esta conversa',
session_link_copied: 'Link da conversa copiado para a área de transferência',
@ -11808,6 +11853,11 @@ const LOCALES = {
path_copy_failed: '경로 복사 실패: ',
session_rename: '대화 이름 변경',
session_rename_desc: '이 대화의 제목 편집',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: '대화 링크 복사',
session_copy_link_desc: '이 대화로 바로 가는 링크 복사',
session_link_copied: '대화 링크가 클립보드에 복사되었습니다',
@ -13050,6 +13100,11 @@ const LOCALES = {
path_copy_failed: 'Échec de la copie du chemin :',
session_rename: 'Renommer la conversation',
session_rename_desc: 'Modifier le titre de cette conversation',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: 'Copier le lien de conversation',
session_copy_link_desc: 'Copier un lien direct vers cette conversation',
session_link_copied: 'Lien de conversation copié dans le presse-papiers',
@ -14372,6 +14427,11 @@ const LOCALES = {
path_copy_failed: 'Yol kopyalanamadı:',
session_rename: 'Konuşmayı yeniden adlandır',
session_rename_desc: 'Bu görüşmenin başlığını düzenleyin',
session_title_regenerate: 'Regenerate title',
session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation',
session_title_regenerating: 'Regenerating title…',
session_title_regenerated: (title) => `Title regenerated: ${title}`,
session_title_regenerate_failed: 'Regenerate title failed: ',
session_copy_link: 'Konuşma bağlantısını kopyala',
session_copy_link_desc: 'Bu konuşmaya doğrudan bağlantı kopyala',
session_link_copied: 'Konuşma bağlantısı panoya kopyalandı',

View file

@ -10,6 +10,7 @@ const ICONS={
trash:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
edit:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M11.5 2.5l2 2L5 13H3v-2z"/><path d="M10 4l2 2"/></svg>',
spark:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M8 1.8l1.1 3.1 3.1 1.1-3.1 1.1L8 10.2 6.9 7.1 3.8 6l3.1-1.1z"/><path d="M12.5 9.5l.5 1.5 1.5.5-1.5.5-.5 1.5-.5-1.5-1.5-.5 1.5-.5z"/></svg>',
link:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M6.7 9.3a3 3 0 0 1 0-4.2l1.7-1.7a3 3 0 0 1 4.2 4.2l-1 1"/><path d="M9.3 6.7a3 3 0 0 1 0 4.2l-1.7 1.7a3 3 0 0 1-4.2-4.2l1-1"/></svg>',
};
@ -2304,6 +2305,39 @@ function _openSessionActionMenu(session, anchorEl){
}
));
}
// Title regeneration matches the backend guard (api/routes.py rejects
// read_only OR is_imported with 403). read_only sessions already bailed at
// the isReadOnly early-return above; skip imported sessions here so the
// action is hidden rather than failing with a 403 toast. This keeps the
// is_imported gate scoped to regenerate instead of broadening the shared
// _isReadOnlySession() helper (which gates rename/pin/archive/etc.).
if(!session.is_imported){
menu.appendChild(_buildSessionAction(
t('session_title_regenerate'),
t('session_title_regenerate_desc'),
ICONS.spark,
async()=>{
closeSessionActionMenu();
try{
if(typeof showToast==='function') showToast(t('session_title_regenerating'), 1600);
const response=await api('/api/session/title/regenerate',{method:'POST',body:JSON.stringify({session_id:session.session_id})});
const nextTitle=(response&&response.title)||(response&&response.session&&response.session.title)||'';
if(nextTitle){
session.title=nextTitle;
const cached=(_allSessions||[]).find(item=>item&&item.session_id===session.session_id);
if(cached) cached.title=nextTitle;
if(S.session&&S.session.session_id===session.session_id){S.session.title=nextTitle;syncTopbar();}
renderSessionListFromCache();
}
if(typeof showToast==='function') showToast(t('session_title_regenerated', nextTitle||t('untitled')), 2400);
}catch(err){
const msg=t('session_title_regenerate_failed')+(err&&err.message?err.message:String(err));
setStatus(msg);
if(typeof showToast==='function') showToast(msg,3000,'error');
}
}
));
}
if(!isExternalSession){
if(session.worktree_path){
menu.appendChild(_buildSessionAction(

View file

@ -24,11 +24,12 @@ class TestSidebarCancelAction:
# Window bumped from 3200 → 4400 in #1764 to accommodate the new
# Rename action item, then to 5200 in #2111 for response-aware archive
# toast handling, then to 6400 in #2294 for the new "Hide from list"
# action prepended for external sessions.
# action prepended for external sessions, then to 7600 in #3106 for
# manual title regeneration controls.
# The `session.active_stream_id` / cancelSessionStream / delete checks
# are positional further down in the function, so growing the prefix
# required growing this read window.
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 6400)
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 7600)
assert "session.active_stream_id" in body, (
"sidebar action menu must detect per-session active_stream_id instead of S.activeStreamId"
)

View file

@ -0,0 +1,123 @@
"""Regression coverage for manual session title regeneration controls (#3106)."""
from pathlib import Path
from unittest.mock import MagicMock
import api.streaming as streaming
ROOT = Path(__file__).resolve().parents[1]
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
STREAMING_PY = (ROOT / "api" / "streaming.py").read_text(encoding="utf-8")
CHANGELOG = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
def test_session_action_menu_exposes_regenerate_title_control():
assert "session_title_regenerate" in SESSIONS_JS
assert "session_title_regenerate_desc" in SESSIONS_JS
assert "ICONS.spark" in SESSIONS_JS
assert "api('/api/session/title/regenerate'" in SESSIONS_JS
assert "renderSessionListFromCache();" in SESSIONS_JS
def test_imported_sessions_skip_regenerate_action_without_broadening_shared_gate():
# The shared _isReadOnlySession() helper must stay scoped to read_only flags
# so it does not silently disable rename/pin/archive/etc. for imported
# sessions. The is_imported guard is scoped to the regenerate action only,
# matching the backend 403 guard in api/routes.py.
helper_idx = SESSIONS_JS.index("function _isReadOnlySession(session)")
next_helper_idx = SESSIONS_JS.index("function _sourceKeyForSession", helper_idx)
helper_block = SESSIONS_JS[helper_idx:next_helper_idx]
assert "session.is_imported" not in helper_block, (
"_isReadOnlySession must not include is_imported — it gates rename/pin/archive too"
)
# The regenerate action is gated on !session.is_imported next to its api call.
regen_idx = SESSIONS_JS.index("api('/api/session/title/regenerate'")
guard_window = SESSIONS_JS[regen_idx - 600:regen_idx]
assert "if(!session.is_imported){" in guard_window
def test_regenerate_title_i18n_and_changelog_entries_exist():
for key in [
"session_title_regenerate",
"session_title_regenerate_desc",
"session_title_regenerating",
"session_title_regenerated",
"session_title_regenerate_failed",
]:
assert key in I18N_JS
assert "session action menu can regenerate conversation titles" in CHANGELOG
assert "#3106" in CHANGELOG
def test_regenerate_endpoint_persists_generated_title_without_reordering_sidebar():
endpoint_idx = ROUTES_PY.index('"/api/session/title/regenerate"')
next_endpoint_idx = ROUTES_PY.index('"/api/personality/set"', endpoint_idx)
block = ROUTES_PY[endpoint_idx:next_endpoint_idx]
assert "generate_session_title_for_session" in block
assert "s.llm_title_generated = True" in block
assert "s.save(touch_updated_at=False)" in block
assert "_sync_session_title_to_insights(s)" in block
assert 'publish_session_list_changed("session_title_regenerate")' in block
assert "Read-only imported sessions cannot be renamed" in block
def test_regenerate_endpoint_syncs_title_to_state_db_when_enabled():
helper_idx = ROUTES_PY.index("def _sync_session_title_to_insights")
endpoint_idx = ROUTES_PY.index('"/api/session/title/regenerate"')
helper_block = ROUTES_PY[helper_idx:endpoint_idx]
assert 'load_settings().get("sync_to_insights")' in helper_block
assert "sync_session_usage" in helper_block
assert "title=session.title" in helper_block
assert "message_count=len(messages)" in helper_block
assert "profile=getattr(session, \"profile\", None)" in helper_block
def test_streaming_helper_generates_title_from_persisted_transcript(monkeypatch):
session = MagicMock()
session.messages = [
{"role": "user", "content": "Please fix the stale sidebar title controls"},
{"role": "assistant", "content": "I will add a regenerate-title action."},
]
class _ProfileEnv:
def __enter__(self):
return None
def __exit__(self, exc_type, exc, tb):
return False
import api.profiles as profiles_api
monkeypatch.setattr(profiles_api, "profile_env_for_background_worker", lambda *args, **kwargs: _ProfileEnv())
monkeypatch.setattr(
streaming,
"_generate_llm_session_title_via_aux",
lambda user, assistant, agent=None: ("Sidebar title controls", "llm", "raw"),
)
title, status, raw = streaming.generate_session_title_for_session(session)
assert title == "Sidebar title controls"
assert status == "llm"
assert raw == "raw"
def test_streaming_helper_has_local_fallback_when_llm_title_is_empty(monkeypatch):
session = MagicMock()
session.messages = [
{"role": "user", "content": "Can you triage this GitHub issue and PR review?"},
{"role": "assistant", "content": "Sure."},
]
class _ProfileEnv:
def __enter__(self):
return None
def __exit__(self, exc_type, exc, tb):
return False
import api.profiles as profiles_api
monkeypatch.setattr(profiles_api, "profile_env_for_background_worker", lambda *args, **kwargs: _ProfileEnv())
monkeypatch.setattr(streaming, "_generate_llm_session_title_via_aux", lambda *args, **kwargs: (None, "llm_empty", ""))
title, status, _raw = streaming.generate_session_title_for_session(session)
assert title == "GitHub Issue Triage"
assert status == "local_summary:llm_empty"