"""Regression test for #1386: CLI session import must not crash when the session is missing from `get_cli_sessions()` metadata at the time of import. Before the fix, `_handle_session_import_cli` only assigned `model` inside the `for cs in get_cli_sessions(): if cs["session_id"] == sid` loop. If the session existed in the messages store but had no metadata row (or had been pruned after `get_cli_session_messages()` was called), `model` was unbound and `import_cli_session(sid, title, msgs, model, ...)` raised `UnboundLocalError`. The fix initializes `model = "unknown"` before the loop so the import proceeds with a sensible default rather than crashing. """ from __future__ import annotations from pathlib import Path REPO = Path(__file__).resolve().parents[1] ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8") def _extract_handler(name: str) -> str: """Return the source of the handler function `name` from api/routes.py.""" marker = f"def {name}(" idx = ROUTES_PY.find(marker) assert idx != -1, f"{name} not found in api/routes.py" # Walk forward until a top-level `def ` (col 0) appears. next_def = ROUTES_PY.find("\ndef ", idx + len(marker)) return ROUTES_PY[idx : next_def if next_def != -1 else len(ROUTES_PY)] def test_import_cli_initializes_model_before_metadata_loop(): """The fallback `model = 'unknown'` must be set BEFORE the `for cs in get_cli_sessions()` loop so that a metadata-less session cannot leave `model` unbound.""" handler = _extract_handler("_handle_session_import_cli") init_idx = handler.find('model = "unknown"') if init_idx == -1: # Allow single quotes too. init_idx = handler.find("model = 'unknown'") assert init_idx != -1, ( "Expected `model = \"unknown\"` initialization in " "_handle_session_import_cli before the metadata loop. Without it, " "import crashes when the session has messages but no metadata row." ) loop_idx = handler.find("for cs in get_cli_sessions()") assert loop_idx != -1, "Expected `for cs in get_cli_sessions()` loop" assert init_idx < loop_idx, ( "`model` must be initialized BEFORE the `for cs in get_cli_sessions()` " "loop, otherwise a session without a metadata row leaves `model` " "unbound and `import_cli_session(..., model, ...)` raises " "UnboundLocalError." ) def test_import_cli_passes_model_to_import_helper(): """Sanity: the handler still passes the resolved model down to `import_cli_session` — the regression test would not catch a refactor that drops the argument entirely.""" handler = _extract_handler("_handle_session_import_cli") assert "import_cli_session(" in handler # The model variable should appear as a positional or keyword arg in # the import_cli_session call. call_idx = handler.find("import_cli_session(") call_block = handler[call_idx : call_idx + 400] assert "model" in call_block, ( "import_cli_session() call should still receive the `model` argument." ) def test_session_import_cli_refresh_matches_messages_despite_timestamp_type_differences(monkeypatch): """Refreshing an imported session should still extend when timestamps differ only by type. Existing WebUI messages can use integer timestamps while CLI refresh returns floating-point timestamps for the same turns. This test verifies the handler accepts that as semantic equality and replaces with the longer, fresher tail. """ import api.routes as routes session_id = "ts_type_diff_001" class FakeSession: def __init__(self): self.messages = [ {"role": "user", "content": "hello", "timestamp": 1710000000}, {"role": "assistant", "content": "working", "timestamp": 1710000001}, ] self.source_tag = "weixin" self.raw_source = "weixin" self.session_source = "messaging" self.source_label = "WeChat" def compact(self): return {"session_id": session_id, "title": "Imported"} def save(self, touch_updated_at=False): save_calls.append(touch_updated_at) save_calls = [] existing = FakeSession() fresh = [ {"role": "user", "content": "hello", "timestamp": 1710000000.0}, {"role": "assistant", "content": "working", "timestamp": 1710000001.0}, {"role": "assistant", "content": "next", "timestamp": 1710000002.0}, ] monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, sid: existing if sid == session_id else 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: fresh if sid == session_id else []) monkeypatch.setattr(routes, "get_cli_sessions", lambda: [{"session_id": session_id, "source_tag": "weixin", "raw_source": "weixin", "session_source": "messaging", "source_label": "WeChat"}]) response = routes._handle_session_import_cli(object(), {"session_id": session_id}) assert response["imported"] is False assert response["session"]["messages"] == fresh assert existing.messages == fresh assert save_calls == [False] def test_session_import_cli_refresh_rejects_prefix_if_non_timing_content_diverges(monkeypatch): """Only true prefixes should be treated as unchanged history during refresh. If the refreshed message body diverges, we should keep the existing in-memory transcript instead of replacing it with potentially older content. """ import api.routes as routes session_id = "ts_type_diverge_001" class FakeSession: def __init__(self): self.messages = [ {"role": "user", "content": "old-prefix", "timestamp": 1710000000}, {"role": "assistant", "content": "from local", "timestamp": 1710000001}, ] self.source_tag = "telegram" self.raw_source = "telegram" self.session_source = "messaging" self.source_label = "Telegram" self.is_cli_session = True def compact(self): return {"session_id": session_id, "title": "Imported"} def save(self, touch_updated_at=False): save_calls.append(touch_updated_at) save_calls = [] existing = FakeSession() fresh = [ {"role": "user", "content": "different-prefix", "timestamp": 1710000000.0}, {"role": "assistant", "content": "from cli", "timestamp": 1710000001.0}, {"role": "assistant", "content": "next", "timestamp": 1710000002.0}, ] monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, sid: existing if sid == session_id else 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: fresh if sid == session_id else []) monkeypatch.setattr(routes, "get_cli_sessions", lambda: [{"session_id": session_id, "source_tag": "telegram", "raw_source": "telegram", "session_source": "messaging", "source_label": "Telegram"}]) response = routes._handle_session_import_cli(object(), {"session_id": session_id}) assert response["imported"] is False assert response["session"]["messages"] == existing.messages assert existing.messages[0]["content"] == "old-prefix" assert save_calls == []