Merge pull request #4145 from nesquena/stage-3998v2
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
Release NK (v0.51.398): auto-generate titles for imported CLI sessions (#3987)
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.398] — 2026-06-13 — Release NK (auto-generate titles for imported CLI sessions, #3987)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Imported CLI/external sessions now get a generated title instead of a raw id or placeholder (#3987).** When a session is imported without a WebUI title, the backend runs the existing `generate_session_title` path (reusing `_looks_like_default_cli_title` detection). The async title persist re-resolves the **latest** canonical session under `_get_session_agent_lock(sid)` immediately before saving (preferring `SESSIONS[sid]`, else `Session.load(sid)`) and re-checks the default-title guard on that latest object — so a WebUI reply that lands while title generation is in flight is never clobbered by a stale pre-generation snapshot. Manual regenerate still works. (#3987)
|
||||
|
||||
## [v0.51.397] — 2026-06-13 — Release NJ (wire /credits through WebUI command dispatch, #4071)
|
||||
|
||||
### Fixed
|
||||
|
||||
170
api/routes.py
170
api/routes.py
@@ -28,6 +28,7 @@ from contextlib import closing
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from api.agent_sessions import (
|
||||
MESSAGING_SOURCES,
|
||||
_looks_like_default_cli_title,
|
||||
is_cli_session_row,
|
||||
is_cli_session_row_visible,
|
||||
read_session_lineage_report,
|
||||
@@ -56,6 +57,132 @@ def _publish_session_list_changed(reason: str, *, profile: str | None = None) ->
|
||||
publish_session_list_changed(reason)
|
||||
|
||||
|
||||
def _sync_session_title_to_insights(session) -> None:
|
||||
"""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)
|
||||
|
||||
|
||||
def _persist_generated_session_title(
|
||||
session,
|
||||
next_title: str,
|
||||
*,
|
||||
event_reason: str,
|
||||
require_default_title: bool = False,
|
||||
) -> str:
|
||||
normalized_title = str(next_title or "").strip()[:80] or "Untitled"
|
||||
sid = str(getattr(session, "session_id", "") or "")
|
||||
original_session = session
|
||||
with _get_session_agent_lock(sid):
|
||||
with LOCK:
|
||||
latest = SESSIONS.get(sid)
|
||||
if latest is not None and str(getattr(latest, "session_id", "") or "") != sid:
|
||||
SESSIONS.pop(sid, None)
|
||||
latest = None
|
||||
elif latest is not None:
|
||||
SESSIONS.move_to_end(sid)
|
||||
if latest is None:
|
||||
latest = Session.load(sid)
|
||||
if latest is None:
|
||||
raise KeyError(sid)
|
||||
session = _ensure_full_session_before_mutation(sid, latest)
|
||||
if getattr(session, "read_only", False):
|
||||
raise PermissionError(f"Session {sid} is read-only")
|
||||
if require_default_title:
|
||||
latest_meta = {
|
||||
"title": getattr(session, "title", None),
|
||||
"source_tag": getattr(session, "source_tag", None),
|
||||
"raw_source": getattr(session, "raw_source", None),
|
||||
"session_source": getattr(session, "session_source", None),
|
||||
"source_label": getattr(session, "source_label", None),
|
||||
}
|
||||
if not _looks_like_default_cli_title(latest_meta):
|
||||
return session.title
|
||||
session.title = normalized_title
|
||||
from api.session_ops import mark_session_title_generated
|
||||
|
||||
# mark_session_title_generated sets s.llm_title_generated = True and clears manual_title.
|
||||
mark_session_title_generated(session)
|
||||
session.save(touch_updated_at=False)
|
||||
with LOCK:
|
||||
SESSIONS[sid] = session
|
||||
SESSIONS.move_to_end(sid)
|
||||
while len(SESSIONS) > SESSIONS_MAX:
|
||||
SESSIONS.popitem(last=False)
|
||||
_sync_session_title_to_insights(session)
|
||||
_publish_session_list_changed(event_reason, profile=getattr(session, "profile", None))
|
||||
if original_session is not session:
|
||||
original_session.title = session.title
|
||||
original_session.llm_title_generated = session.llm_title_generated
|
||||
original_session.manual_title = session.manual_title
|
||||
return session.title
|
||||
|
||||
|
||||
def _queue_generated_title_for_imported_session(session, cli_meta: dict | None) -> None:
|
||||
try:
|
||||
cli_meta = dict(cli_meta or {})
|
||||
if not session or cli_meta.get("read_only") or not _looks_like_default_cli_title(cli_meta):
|
||||
return
|
||||
sid = str(getattr(session, "session_id", "") or "")
|
||||
if not sid:
|
||||
return
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
current = Session.load(sid)
|
||||
if not current:
|
||||
return
|
||||
current = _ensure_full_session_before_mutation(sid, current)
|
||||
if getattr(current, "read_only", False):
|
||||
return
|
||||
current_meta = {
|
||||
"title": getattr(current, "title", None),
|
||||
"source_tag": getattr(current, "source_tag", None),
|
||||
"raw_source": getattr(current, "raw_source", None),
|
||||
"session_source": getattr(current, "session_source", None),
|
||||
"source_label": getattr(current, "source_label", None),
|
||||
}
|
||||
if not _looks_like_default_cli_title(current_meta):
|
||||
return
|
||||
next_title, _reason, _raw_preview = generate_session_title_for_session(current)
|
||||
normalized_current = str(getattr(current, "title", "") or "").strip()
|
||||
normalized_next = str(next_title or "").strip()
|
||||
if not normalized_next or normalized_next == normalized_current:
|
||||
return
|
||||
_persist_generated_session_title(
|
||||
current,
|
||||
normalized_next,
|
||||
event_reason="session_title_regenerate",
|
||||
require_default_title=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to generate imported session title for %s", sid, exc_info=True)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name=f"imported-title-{sid}").start()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to queue imported session title generation for %s",
|
||||
getattr(session, "session_id", None),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
# ── Cron run tracking ────────────────────────────────────────────────────────
|
||||
# Track job IDs currently being executed so the frontend can poll status.
|
||||
_RUNNING_CRON_JOBS: dict[str, float] = {} # job_id → start_timestamp
|
||||
@@ -7591,27 +7718,6 @@ 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")
|
||||
@@ -7644,19 +7750,12 @@ def handle_post(handler, parsed) -> bool:
|
||||
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):
|
||||
if getattr(s, "read_only", 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"
|
||||
from api.session_ops import mark_session_title_generated
|
||||
# mark_session_title_generated sets s.llm_title_generated = True and clears manual_title.
|
||||
mark_session_title_generated(s)
|
||||
s.save(touch_updated_at=False)
|
||||
_sync_session_title_to_insights(s)
|
||||
publish_session_list_changed("session_title_regenerate", profile=getattr(s, "profile", None))
|
||||
_persist_generated_session_title(s, next_title, event_reason="session_title_regenerate")
|
||||
return j(handler, {
|
||||
"session": s.compact(),
|
||||
"title": s.title,
|
||||
@@ -16104,6 +16203,17 @@ def _handle_session_import_cli(handler, body):
|
||||
"session_import_cli",
|
||||
profile=getattr(s, "profile", None),
|
||||
)
|
||||
_queue_generated_title_for_imported_session(
|
||||
s,
|
||||
{
|
||||
"title": cli_title,
|
||||
"source_tag": cli_source_tag,
|
||||
"raw_source": cli_raw_source,
|
||||
"session_source": cli_session_source,
|
||||
"source_label": cli_source_label,
|
||||
"read_only": cli_read_only,
|
||||
},
|
||||
)
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
|
||||
@@ -3181,39 +3181,33 @@ 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');
|
||||
// Title regeneration stays available for writable imported sessions.
|
||||
// Read-only sessions return earlier through the shared action-menu guard.
|
||||
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(
|
||||
|
||||
@@ -219,6 +219,71 @@ def test_session_import_cli_returns_read_only_claude_code_payload(monkeypatch, t
|
||||
assert session["is_cli_session"] is True
|
||||
|
||||
|
||||
def test_session_import_cli_queues_generated_title_for_writable_default_cli_title(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
sid = "cli_writable_default_title"
|
||||
messages = [{"role": "user", "content": "Need a better imported title"}]
|
||||
cli_meta = {
|
||||
"session_id": sid,
|
||||
"title": "CLI Session",
|
||||
"model": "claude-code",
|
||||
"created_at": 10.0,
|
||||
"updated_at": 20.0,
|
||||
"source_tag": "cli",
|
||||
"raw_source": "cli",
|
||||
"session_source": "external_agent",
|
||||
"source_label": "CLI",
|
||||
"is_cli_session": True,
|
||||
"read_only": False,
|
||||
}
|
||||
persisted = {}
|
||||
queued = []
|
||||
published = []
|
||||
|
||||
class FakeImportedSession:
|
||||
def __init__(self):
|
||||
self.session_id = sid
|
||||
self.title = "CLI Session"
|
||||
self.messages = list(messages)
|
||||
self.profile = "default"
|
||||
self.model = "claude-code"
|
||||
self.read_only = False
|
||||
self.is_cli_session = True
|
||||
|
||||
def save(self, touch_updated_at=False):
|
||||
persisted["saved"] = touch_updated_at
|
||||
|
||||
def compact(self):
|
||||
return {"session_id": sid, "title": self.title}
|
||||
|
||||
imported = FakeImportedSession()
|
||||
|
||||
monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, _sid: None))
|
||||
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
|
||||
monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status})
|
||||
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
||||
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid: messages if _sid == sid else [])
|
||||
monkeypatch.setattr(routes, "get_cli_sessions", lambda: [cli_meta])
|
||||
monkeypatch.setattr(routes, "import_cli_session", lambda *args, **kwargs: imported)
|
||||
monkeypatch.setattr(routes, "publish_session_list_changed", lambda reason, profile=None: published.append((reason, profile)))
|
||||
monkeypatch.setattr(routes, "_queue_generated_title_for_imported_session", lambda session, meta: queued.append((session, meta.copy())))
|
||||
|
||||
response = routes._handle_session_import_cli(object(), {"session_id": sid})
|
||||
|
||||
assert response["imported"] is True
|
||||
assert persisted["saved"] is False
|
||||
assert published == [("session_import_cli", "default")]
|
||||
assert queued == [(imported, {
|
||||
"title": "CLI Session",
|
||||
"source_tag": "cli",
|
||||
"raw_source": "cli",
|
||||
"session_source": "external_agent",
|
||||
"source_label": "CLI",
|
||||
"read_only": False,
|
||||
})]
|
||||
|
||||
|
||||
def test_read_only_source_badge_ui_guards_are_present():
|
||||
sessions_js = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
messages_js = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
|
||||
224
tests/test_issue3987_imported_session_titles.py
Normal file
224
tests/test_issue3987_imported_session_titles.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Regression coverage for imported-session title generation after CLI import (#3987)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
import api.models as models
|
||||
import api.routes as routes
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.headers = {}
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.headers[key] = value
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
def json_body(self):
|
||||
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
||||
|
||||
|
||||
def test_import_cli_handler_queues_default_titles_after_persisting_import():
|
||||
handler_idx = ROUTES_PY.index("def _handle_session_import_cli")
|
||||
next_handler_idx = ROUTES_PY.index("def _handle_session_import(", handler_idx)
|
||||
block = ROUTES_PY[handler_idx:next_handler_idx]
|
||||
queue_idx = block.index("_queue_generated_title_for_imported_session(")
|
||||
publish_idx = block.index('publish_session_list_changed(\n "session_import_cli",')
|
||||
response_idx = block.index("return j(", queue_idx)
|
||||
assert publish_idx < queue_idx < response_idx
|
||||
queue_window = block[queue_idx:queue_idx + 400]
|
||||
assert '"title": cli_title' in queue_window
|
||||
assert '"read_only": cli_read_only' in queue_window
|
||||
|
||||
|
||||
def test_import_cli_queue_helper_is_guarded_and_runs_in_background():
|
||||
helper_idx = ROUTES_PY.index("def _queue_generated_title_for_imported_session")
|
||||
next_helper_idx = ROUTES_PY.index("def _gateway_sse_probe_payload", helper_idx)
|
||||
block = ROUTES_PY[helper_idx:next_helper_idx]
|
||||
assert "cli_meta.get(\"read_only\")" in block
|
||||
assert "not _looks_like_default_cli_title(cli_meta)" in block
|
||||
assert "if not _looks_like_default_cli_title(current_meta):" in block
|
||||
assert "generate_session_title_for_session(current)" in block
|
||||
assert "require_default_title=True" in block
|
||||
assert "threading.Thread(target=_run, daemon=True" in block
|
||||
|
||||
|
||||
def test_import_cli_queue_helper_generates_title_once_for_placeholder_session(monkeypatch):
|
||||
persisted = []
|
||||
generated = []
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, title):
|
||||
self.session_id = "cli_queued_title"
|
||||
self.title = title
|
||||
self.source_tag = "cli"
|
||||
self.raw_source = "cli"
|
||||
self.session_source = "external_agent"
|
||||
self.source_label = "CLI"
|
||||
self.read_only = False
|
||||
|
||||
current = FakeSession("CLI Session")
|
||||
|
||||
class InlineThread:
|
||||
def __init__(self, *, target, daemon, name):
|
||||
self._target = target
|
||||
|
||||
def start(self):
|
||||
self._target()
|
||||
|
||||
monkeypatch.setattr(routes.threading, "Thread", InlineThread)
|
||||
monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, sid: current if sid == current.session_id else None))
|
||||
monkeypatch.setattr(routes, "_ensure_full_session_before_mutation", lambda sid, session: session)
|
||||
monkeypatch.setattr(routes, "generate_session_title_for_session", lambda session: (generated.append(session.session_id) or "Better imported title", "llm", "raw"))
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_persist_generated_session_title",
|
||||
lambda session, title, *, event_reason, require_default_title=False: persisted.append(
|
||||
(session.session_id, title, event_reason, require_default_title)
|
||||
),
|
||||
)
|
||||
|
||||
routes._queue_generated_title_for_imported_session(current, {"title": "CLI Session", "source_tag": "cli"})
|
||||
|
||||
assert generated == [current.session_id]
|
||||
assert persisted == [(current.session_id, "Better imported title", "session_title_regenerate", True)]
|
||||
|
||||
|
||||
def test_import_cli_queue_helper_skips_sessions_that_already_have_real_titles(monkeypatch):
|
||||
generated = []
|
||||
persisted = []
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self):
|
||||
self.session_id = "cli_real_title"
|
||||
self.title = "Useful imported title"
|
||||
self.source_tag = "cli"
|
||||
self.raw_source = "cli"
|
||||
self.session_source = "external_agent"
|
||||
self.source_label = "CLI"
|
||||
self.read_only = False
|
||||
|
||||
current = FakeSession()
|
||||
|
||||
class InlineThread:
|
||||
def __init__(self, *, target, daemon, name):
|
||||
self._target = target
|
||||
|
||||
def start(self):
|
||||
self._target()
|
||||
|
||||
monkeypatch.setattr(routes.threading, "Thread", InlineThread)
|
||||
monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, sid: current if sid == current.session_id else None))
|
||||
monkeypatch.setattr(routes, "_ensure_full_session_before_mutation", lambda sid, session: session)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"generate_session_title_for_session",
|
||||
lambda session: generated.append(session.session_id) or ("Unexpected generated title", "llm", "raw"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_persist_generated_session_title",
|
||||
lambda session, title, *, event_reason, require_default_title=False: persisted.append(
|
||||
(session.session_id, title, event_reason, require_default_title)
|
||||
),
|
||||
)
|
||||
|
||||
routes._queue_generated_title_for_imported_session(
|
||||
current,
|
||||
{"title": "CLI Session", "source_tag": "cli"},
|
||||
)
|
||||
|
||||
assert generated == []
|
||||
assert persisted == []
|
||||
|
||||
|
||||
def test_generated_title_persist_reloads_latest_session_before_saving(tmp_path, monkeypatch):
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
cache = OrderedDict()
|
||||
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSIONS", cache, raising=False)
|
||||
monkeypatch.setattr(routes, "SESSIONS", cache, raising=False)
|
||||
monkeypatch.setattr(routes, "_sync_session_title_to_insights", lambda session: None)
|
||||
monkeypatch.setattr(routes, "_publish_session_list_changed", lambda *args, **kwargs: None)
|
||||
|
||||
stale = models.Session(
|
||||
session_id="cli_import_race",
|
||||
title="CLI Session",
|
||||
workspace=".",
|
||||
model="test-model",
|
||||
messages=[{"role": "user", "content": "first"}],
|
||||
source_tag="cli",
|
||||
raw_source="cli",
|
||||
session_source="external_agent",
|
||||
source_label="CLI",
|
||||
)
|
||||
stale.save(skip_index=True)
|
||||
stale_snapshot = models.Session.load(stale.session_id)
|
||||
|
||||
latest = models.Session(
|
||||
session_id=stale.session_id,
|
||||
title="CLI Session",
|
||||
workspace=".",
|
||||
model="test-model",
|
||||
messages=[
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
{"role": "user", "content": "second"},
|
||||
],
|
||||
source_tag="cli",
|
||||
raw_source="cli",
|
||||
session_source="external_agent",
|
||||
source_label="CLI",
|
||||
)
|
||||
latest.save(skip_index=True)
|
||||
cache[latest.session_id] = latest
|
||||
|
||||
saved_title = routes._persist_generated_session_title(
|
||||
stale_snapshot,
|
||||
"Better imported title",
|
||||
event_reason="session_title_regenerate",
|
||||
require_default_title=True,
|
||||
)
|
||||
|
||||
reloaded = models.Session.load(stale.session_id)
|
||||
assert saved_title == "Better imported title"
|
||||
assert reloaded.title == "Better imported title"
|
||||
assert len(reloaded.messages) == 3
|
||||
assert reloaded.llm_title_generated is True
|
||||
assert reloaded.manual_title is False
|
||||
assert len(cache[stale.session_id].messages) == 3
|
||||
assert stale_snapshot.title == "Better imported title"
|
||||
|
||||
|
||||
def test_regenerate_endpoint_only_blocks_read_only_imported_sessions():
|
||||
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 'if getattr(s, "read_only", False):' in block
|
||||
assert 'getattr(s, "is_imported", False)' not in block
|
||||
|
||||
|
||||
def test_sessions_ui_keeps_regenerate_action_for_writable_imports():
|
||||
regen_idx = SESSIONS_JS.index("api('/api/session/title/regenerate'")
|
||||
window = SESSIONS_JS[regen_idx - 500:regen_idx]
|
||||
assert "session.is_imported" not in window
|
||||
assert "_isReadOnlySession(session)" in SESSIONS_JS
|
||||
@@ -31,6 +31,8 @@ def test_session_events_publish_for_minimal_sidebar_mutations():
|
||||
):
|
||||
if reason == "session_import_cli":
|
||||
assert f'publish_session_list_changed(\n "{reason}",' in ROUTES, reason
|
||||
elif reason == "session_title_regenerate":
|
||||
assert '_persist_generated_session_title(s, next_title, event_reason="session_title_regenerate")' in ROUTES
|
||||
elif reason == "session_import":
|
||||
assert f'publish_session_list_changed("{reason}")' in ROUTES, reason
|
||||
else:
|
||||
@@ -41,7 +43,8 @@ def test_session_events_publish_for_minimal_sidebar_mutations():
|
||||
assert 'if was_hidden_empty_session:\n publish_session_list_changed("session_new", profile=getattr(s, "profile", None))' in ROUTES
|
||||
assert 'publish_session_list_changed("session_duplicate", profile=getattr(copied_session, "profile", None))' in ROUTES
|
||||
assert 'publish_session_list_changed("session_rename", profile=getattr(s, "profile", None))' in ROUTES
|
||||
assert 'publish_session_list_changed("session_title_regenerate", profile=getattr(s, "profile", None))' in ROUTES
|
||||
assert '_persist_generated_session_title(s, next_title, event_reason="session_title_regenerate")' in ROUTES
|
||||
assert '_publish_session_list_changed(event_reason, profile=getattr(session, "profile", None))' in ROUTES
|
||||
assert 'event_profile = getattr(get_session(sid, metadata_only=True), "profile", None)' in ROUTES
|
||||
assert "Failed to resolve profile for deleted session" in ROUTES
|
||||
assert '_publish_session_list_changed("session_delete", profile=event_profile)' in ROUTES
|
||||
|
||||
@@ -21,21 +21,19 @@ def test_session_action_menu_exposes_regenerate_title_control():
|
||||
assert "renderSessionListFromCache();" in SESSIONS_JS
|
||||
|
||||
|
||||
def test_imported_sessions_skip_regenerate_action_without_broadening_shared_gate():
|
||||
def test_writable_imported_sessions_keep_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.
|
||||
# sessions. Writable imported sessions should still expose regenerate.
|
||||
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"
|
||||
"_isReadOnlySession must not include is_imported; writable imports need regenerate"
|
||||
)
|
||||
# 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
|
||||
assert "session.is_imported" not in guard_window
|
||||
|
||||
|
||||
def test_regenerate_title_i18n_and_changelog_entries_exist():
|
||||
@@ -56,13 +54,20 @@ def test_regenerate_endpoint_persists_generated_title_without_reordering_sidebar
|
||||
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", profile=getattr(s, "profile", None))' in block
|
||||
assert '_persist_generated_session_title(s, next_title, event_reason="session_title_regenerate")' in block
|
||||
assert "Read-only imported sessions cannot be renamed" in block
|
||||
|
||||
|
||||
def test_regenerate_helper_persists_generated_title_and_publishes_sidebar_refresh():
|
||||
helper_idx = ROUTES_PY.index("def _persist_generated_session_title")
|
||||
queue_idx = ROUTES_PY.index("def _queue_generated_title_for_imported_session", helper_idx)
|
||||
helper_block = ROUTES_PY[helper_idx:queue_idx]
|
||||
assert "mark_session_title_generated(session)" in helper_block
|
||||
assert "session.save(touch_updated_at=False)" in helper_block
|
||||
assert "_sync_session_title_to_insights(session)" in helper_block
|
||||
assert '_publish_session_list_changed(event_reason, profile=getattr(session, "profile", None))' in helper_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"')
|
||||
|
||||
Reference in New Issue
Block a user