"""Regression guards for cross-channel handoff UI and summary generation.""" import json import time import sqlite3 from pathlib import Path import sys import types ROOT = Path(__file__).resolve().parents[1] INDEX = (ROOT / "static" / "index.html").read_text(encoding="utf-8") SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") ROUTES = (ROOT / "api" / "routes.py").read_text(encoding="utf-8") UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") def _new_state_db(path: Path) -> sqlite3.Connection: """Create a minimal state.db shape for handoff-summary persistence tests.""" conn = sqlite3.connect(str(path)) conn.executescript( """ CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, source TEXT NOT NULL, title TEXT, model TEXT, started_at REAL NOT NULL, message_count INTEGER DEFAULT 0, parent_session_id TEXT, ended_at REAL, end_reason TEXT ); CREATE TABLE IF NOT EXISTS messages ( session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, timestamp REAL ); """ ) return conn def _extract_handoff_marker_payload(message): content = message.get("content") if isinstance(message, dict) else None if not isinstance(content, str): return None try: data = json.loads(content) except json.JSONDecodeError: return None if not isinstance(data, dict): return None if not data.get("_handoff_summary_card"): return None return data def test_handoff_hint_is_docked_in_composer_flyout_not_transcript(): """Handoff should use the Terminal-style composer dock, not transcript flow.""" marker = '
') composer_flyout_idx = INDEX.index('
') handoff_idx = INDEX.index(marker) assert handoff_idx > composer_flyout_idx assert not (msg_inner_idx < handoff_idx < composer_flyout_idx) def test_handoff_dock_reserves_transcript_space_like_terminal_dock(): assert ".messages.handoff-dock-visible" in STYLE_CSS assert ".handoff-hint-container{position:absolute" in STYLE_CSS assert "_syncHandoffDockSpace(true)" in SESSIONS_JS assert "_syncHandoffDockSpace(false)" in SESSIONS_JS def test_handoff_dock_width_aligns_with_existing_slide_up_panels(): assert ".handoff-hint-container{position:absolute;left:0;right:0;bottom:-2px;width:min(calc(100% - 112px),560px);" in STYLE_CSS assert ".handoff-hint-container{bottom:-2px;width:calc(100% - 28px);}" in STYLE_CSS start = STYLE_CSS.find(".handoff-hint-container") assert start != -1 end = STYLE_CSS.find("}", start) assert end != -1 handoff_hint_rule = STYLE_CSS[start:end+1] assert "width:min(calc(100% - 112px),560px)" in handoff_hint_rule assert "border-bottom:none;border-radius:13px 13px 0 0" in STYLE_CSS assert "padding:7px 12px 9px" in STYLE_CSS assert ".handoff-hint-text{min-width:0;display:flex;align-items:center;gap:10px;color:var(--muted);font-size:12px;font-weight:700;line-height:1.2;" in STYLE_CSS assert ".handoff-hint-action,.handoff-hint-dismiss{border:none;background:transparent;color:var(--muted);font:inherit;font-size:12px;font-weight:700;line-height:1.2;" in STYLE_CSS assert ".handoff-hint-dot{width:7px;height:7px;border-radius:999px;background:var(--success);" in STYLE_CSS def test_handoff_summary_fallback_displays_clear_user_note(): assert "const isFallback=!!state.fallback;" in UI_JS assert "class=\"handoff-summary-fallback-note\"" in UI_JS assert "Fallback summary generated from recent turns; no model-based rewrite was used." in UI_JS def test_handoff_delete_clears_local_storage_markers(): assert "function _clearHandoffStorageForSession(sid) {" in SESSIONS_JS assert "_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT, null);" in SESSIONS_JS assert "_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT, null);" in SESSIONS_JS assert "_clearHandoffStorageForSession(sid);" in SESSIONS_JS assert "ids.forEach(_clearHandoffStorageForSession);" in SESSIONS_JS def test_handoff_summary_renders_as_transcript_card_not_dock_card(): assert "function setHandoffUi" in SESSIONS_JS or "function setHandoffUi" in (ROOT / "static" / "ui.js").read_text(encoding="utf-8") ui_js = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") assert "_handoffCardsNode" in ui_js assert "data-handoff-card" in ui_js assert 'data-compression-card="1" data-handoff-card="1"' in ui_js assert 'class="tool-card-result handoff-summary-body"' in ui_js assert "renderMd(detail)" in ui_js assert "_insertCompressionLikeNode(handoffState?_handoffCardsNode" in ui_js assert "window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid)" in ui_js assert "!hasTransientTranscriptUi" in ui_js assert "handoff-summary-card" not in SESSIONS_JS assert "handoff-summary-card" not in STYLE_CSS def test_handoff_summary_card_rendering_uses_persisted_messages(): """Persistent summary markers are parsed from message history and rendered via compression-like cards.""" assert "_collectHandoffSummaryStates" in UI_JS assert "_handoffSummaryStateFromMessage" in UI_JS assert "_handoffSummaryPayload" in UI_JS or "_parseHandoffSummaryPayload" in UI_JS assert "_insertCompressionLikeNodeByRawIdx" in UI_JS assert "_isHandoffSummaryToolPayload" in UI_JS assert "_buildHandoffSummaryToolMessage" in SESSIONS_JS def test_handoff_summary_does_not_call_removed_agent_get_response(): """Current Hermes Agent exposes run_conversation/private transports, not get_response.""" handoff_start = ROUTES.index("def _handle_handoff_summary") next_handler = ROUTES.index("\ndef _handle_skill_save", handoff_start) handoff_body = ROUTES[handoff_start:next_handler] assert ".get_response(" not in handoff_body assert "_agent_text_completion" in handoff_body assert "_fallback_handoff_summary" in handoff_body def test_handoff_summary_prompt_uses_you_and_你(): """Summary prompt should use assistant-facing pronouns instead of “user/用户”.""" handoff_start = ROUTES.index("def _handle_handoff_summary") next_handler = ROUTES.index("\ndef _handle_skill_save", handoff_start) handoff_body = ROUTES[handoff_start:next_handler] prompt_start = handoff_body.index("summary_system_prompt = (") prompt_end = handoff_body.index("summary_user_text =", prompt_start) prompt_body = handoff_body[prompt_start:prompt_end] assert "speak using “you”" in prompt_body assert "用“你”" in prompt_body assert "the user" not in prompt_body.lower() assert "用户" not in prompt_body def test_generating_handoff_summary_marks_session_as_handled(): """Summary success uses a max(dismissed/handled) baseline for future checks.""" generate_start = SESSIONS_JS.index("async function _generateHandoffSummary") resolve_start = SESSIONS_JS.index("function _resolveSessionModelForDisplaySoon", generate_start) generate_body = SESSIONS_JS[generate_start:resolve_start] dismiss_start = SESSIONS_JS.index("function _dismissHandoffHint") generate_start_after_dismiss = SESSIONS_JS.index("async function _generateHandoffSummary", dismiss_start) dismiss_body = SESSIONS_JS[dismiss_start:generate_start_after_dismiss] assert "_getHandoffSince(sid)" in generate_body assert "_setHandoffSummaryHandledAt(sid, Date.now() / 1000)" in generate_body assert "_hasMatchingHandoffSummary" not in generate_body assert "_setHandoffDismissedAt(" in dismiss_body assert "_setHandoffSummaryHandledAt(" not in dismiss_body assert "_HANDOFF_SUFFIX_SUMMARY_HANDLED_AT" in SESSIONS_JS assert "setHandoffUi({" in generate_body assert "phase: 'done'" not in generate_body assert "_getHandoffSince(sid)" in SESSIONS_JS assert "_HANDOFF_SUFFIX_SUMMARY_HANDLED_AT" in SESSIONS_JS assert "_HANDOFF_SUFFIX_DISMISSED_AT" in SESSIONS_JS def test_handoff_hints_use_max_baseline_since(): """Handled and dismissed state are coalesced with max() before calling conversation-rounds.""" check_start = SESSIONS_JS.index("async function _checkAndShowHandoffHint") resolve_start = SESSIONS_JS.index("function _showHandoffHint", check_start) check_body = SESSIONS_JS[check_start:resolve_start] assert "_getHandoffSince(sid)" in check_body assert "_getHandoffSummaryHandledAt(sid)" in SESSIONS_JS assert "_getHandoffDismissedAt(sid)" in SESSIONS_JS assert "Math.max(dismissedAt, summaryHandledAt)" in SESSIONS_JS assert "_isHandoffSummaryHandled" not in SESSIONS_JS def test_no_api_key_handoff_summary_persists_fallback_summary(monkeypatch): """No-API-key path should persist fallback summary markers.""" import api.routes as routes import api.config as cfg import api.models as models # Force API-path validation to focus on fallback behavior only. 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) persisted = [] monkeypatch.setattr( routes, "_persist_handoff_summary", lambda sid, summary, channel, rounds, fallback=False: persisted.append({ "sid": sid, "summary": summary, "channel": channel, "rounds": rounds, "fallback": fallback, }) or {"ok": True}, ) monkeypatch.setattr(models, "count_conversation_rounds", lambda sid, since=None: models.CONVERSATION_ROUND_THRESHOLD) monkeypatch.setattr( models, "get_cli_session_messages", lambda sid: [ {"role": "user", "content": "Need help with setup", "timestamp": 1.0}, {"role": "assistant", "content": "I'll help you", "timestamp": 2.0}, ], ) monkeypatch.setattr(cfg, "resolve_model_provider", lambda resolved_model=None: ("gpt-test", "openrouter", None)) fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider") fake_runtime_module.resolve_runtime_provider = lambda requested=None: {"api_key": "", "provider": "openrouter", "base_url": None} fake_hermes_cli = types.ModuleType("hermes_cli") fake_hermes_cli.__path__ = [] fake_hermes_cli.runtime_provider = fake_runtime_module monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli) monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_module) response = routes._handle_handoff_summary(object(), {"session_id": "session-without-api-key"}) assert response["ok"] is True assert response["fallback"] is True assert response["summary"].startswith("-") assert "You asked:" in response["summary"] assert "Recent external-channel activity:" not in response["summary"] assert len(persisted) == 1 assert persisted[0]["sid"] == "session-without-api-key" assert persisted[0]["fallback"] is True assert persisted[0]["rounds"] == models.CONVERSATION_ROUND_THRESHOLD def test_exception_handoff_summary_persists_fallback_summary(monkeypatch): """Unhandled summary exception should still persist a fallback handoff marker.""" import api.routes as routes import api.config as cfg import api.models as models 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) persisted = [] monkeypatch.setattr( routes, "_persist_handoff_summary", lambda sid, summary, channel, rounds, fallback=False: persisted.append({ "sid": sid, "summary": summary, "channel": channel, "rounds": rounds, "fallback": fallback, }) or {"ok": True}, ) monkeypatch.setattr(models, "count_conversation_rounds", lambda sid, since=None: models.CONVERSATION_ROUND_THRESHOLD) monkeypatch.setattr( models, "get_cli_session_messages", lambda sid: [ {"role": "user", "content": "Could you check this?", "timestamp": 1.0}, {"role": "assistant", "content": "Sure, I can help", "timestamp": 2.0}, ], ) monkeypatch.setattr(cfg, "resolve_model_provider", lambda resolved_model=None: ("gpt-test", "openrouter", None)) fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider") fake_runtime_module.resolve_runtime_provider = lambda requested=None: { "api_key": "x", "provider": "openrouter", "base_url": None, } fake_hermes_cli = types.ModuleType("hermes_cli") fake_hermes_cli.__path__ = [] fake_hermes_cli.runtime_provider = fake_runtime_module monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli) monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_module) class _Client: class completions: @staticmethod def create(*args, **kwargs): raise RuntimeError("intentional handoff-summary failure") class _Chat: completions = _Client.completions class _OpenAIClient: chat = _Chat class _FailingAgent: api_mode = "" def __init__(self, *args, **kwargs): self.model = kwargs.get("model") self.reasoning_config = None def _build_api_kwargs(self, *args, **kwargs): return {} def _ensure_primary_openai_client(self, reason=None): return _OpenAIClient() def release_clients(self): return None fake_run_agent = types.ModuleType("run_agent") fake_run_agent.AIAgent = _FailingAgent monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) response = routes._handle_handoff_summary(object(), {"session_id": "session-with-exception"}) assert response["ok"] is True assert response["fallback"] is True assert response["summary"].startswith("-") assert "You asked:" in response["summary"] assert "Recent external-channel activity:" not in response["summary"] assert "warning" in response assert len(persisted) == 1 assert persisted[0]["sid"] == "session-with-exception" assert persisted[0]["fallback"] is True assert persisted[0]["rounds"] == models.CONVERSATION_ROUND_THRESHOLD def test_handoff_summary_retries_once_when_length_limit_reached(monkeypatch): """finish_reason='length' should trigger one retry with larger budget.""" import api.routes as routes import api.config as cfg import api.models as models 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) persisted = [] monkeypatch.setattr( routes, "_persist_handoff_summary", lambda sid, summary, channel, rounds, fallback=False: persisted.append({ "sid": sid, "summary": summary, "channel": channel, "rounds": rounds, "fallback": fallback, }) or {"ok": True}, ) monkeypatch.setattr(models, "count_conversation_rounds", lambda sid, since=None: models.CONVERSATION_ROUND_THRESHOLD) monkeypatch.setattr( models, "get_cli_session_messages", lambda sid: [ {"role": "user", "content": "Can we switch to a different method?", "timestamp": 1.0}, {"role": "assistant", "content": "Sure, here is the outline.", "timestamp": 2.0}, {"role": "user", "content": "Keep going.", "timestamp": 3.0}, {"role": "assistant", "content": "Step 1 is done, step 2 is pending.", "timestamp": 4.0}, ], ) monkeypatch.setattr(cfg, "resolve_model_provider", lambda resolved_model=None: ("gpt-test", "openrouter", None)) completion_calls = [] def _choice(content, finish_reason="stop"): return types.SimpleNamespace( message=types.SimpleNamespace(content=content), finish_reason=finish_reason, ) class _Client: class completions: @staticmethod def create(*args, **kwargs): max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") completion_calls.append(max_tokens) if len(completion_calls) == 1: return types.SimpleNamespace(choices=[ _choice("- You can do step A, B, and C", finish_reason="length") ]) return types.SimpleNamespace(choices=[ _choice("- You should continue with step D.\n- You can then review results.", finish_reason="stop") ]) class _Chat: completions = _Client.completions class _OpenAIClient: chat = _Chat class _LengthAwareAgent: api_mode = "" def __init__(self, *args, **kwargs): self.model = kwargs.get("model") self.reasoning_config = None def _build_api_kwargs(self, *args, **kwargs): return {} def _ensure_primary_openai_client(self, reason=None): return _OpenAIClient() def release_clients(self): return None fake_run_agent = types.ModuleType("run_agent") fake_run_agent.AIAgent = _LengthAwareAgent monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider") fake_runtime_module.resolve_runtime_provider = lambda requested=None: { "api_key": "x", "provider": "openrouter", "base_url": None, } fake_hermes_cli = types.ModuleType("hermes_cli") fake_hermes_cli.__path__ = [] fake_hermes_cli.runtime_provider = fake_runtime_module monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli) monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_module) response = routes._handle_handoff_summary(object(), {"session_id": "session-length-retry"}) assert response["ok"] is True assert response["fallback"] is False assert response["summary"].startswith("- You should continue with step D.") assert completion_calls == [700, 1400] assert len(persisted) == 1 assert persisted[0]["fallback"] is False assert persisted[0]["sid"] == "session-length-retry" def test_handoff_summary_falls_back_when_retry_still_incomplete(monkeypatch): """Retry may still truncate; fallback should still return deterministic concise bullets.""" import api.routes as routes import api.config as cfg import api.models as models 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) persisted = [] monkeypatch.setattr( routes, "_persist_handoff_summary", lambda sid, summary, channel, rounds, fallback=False: persisted.append({ "sid": sid, "summary": summary, "channel": channel, "rounds": rounds, "fallback": fallback, }) or {"ok": True}, ) monkeypatch.setattr(models, "count_conversation_rounds", lambda sid, since=None: models.CONVERSATION_ROUND_THRESHOLD) monkeypatch.setattr( models, "get_cli_session_messages", lambda sid: [ {"role": "user", "content": "Could you plan next moves?", "timestamp": 1.0}, {"role": "assistant", "content": "Let's draft a schedule.", "timestamp": 2.0}, {"role": "user", "content": "Anything else?", "timestamp": 3.0}, {"role": "assistant", "content": "Yes, one more check is needed.", "timestamp": 4.0}, ], ) monkeypatch.setattr(cfg, "resolve_model_provider", lambda resolved_model=None: ("gpt-test", "openrouter", None)) class _Client: class completions: @staticmethod def create(*args, **kwargs): return types.SimpleNamespace(choices=[ types.SimpleNamespace( message=types.SimpleNamespace( content="I can help summarize this but", ), finish_reason="length", ) ]) class _Chat: completions = _Client.completions class _LengthAwareAgent: api_mode = "" def __init__(self, *args, **kwargs): self.model = kwargs.get("model") self.reasoning_config = None def _build_api_kwargs(self, *args, **kwargs): return {} def _ensure_primary_openai_client(self, reason=None): return _Chat() def release_clients(self): return None fake_run_agent = types.ModuleType("run_agent") fake_run_agent.AIAgent = _LengthAwareAgent monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider") fake_runtime_module.resolve_runtime_provider = lambda requested=None: { "api_key": "x", "provider": "openrouter", "base_url": None, } fake_hermes_cli = types.ModuleType("hermes_cli") fake_hermes_cli.__path__ = [] fake_hermes_cli.runtime_provider = fake_runtime_module monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli) monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_module) response = routes._handle_handoff_summary(object(), {"session_id": "session-length-fallback"}) assert response["ok"] is True assert response["fallback"] is True assert response["summary"].startswith("- You asked:") assert "Recent external-channel activity:" not in response["summary"] assert len(persisted) == 1 assert persisted[0]["fallback"] is True assert persisted[0]["sid"] == "session-length-fallback" def test_handoff_summary_persistence_targets_both_backends_for_messaging_session(tmp_path, monkeypatch): """Messaging sessions should persist handoff summary markers into both local JSON and state.db.""" import api.routes as routes import api.models as models import api.profiles as profiles sid = "messaging_1013_both_backends_01" mock_home = tmp_path / "hermes_home" mock_home.mkdir() mock_sessions = tmp_path / "sessions" mock_sessions.mkdir() monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: mock_home) monkeypatch.setattr(models, "SESSION_DIR", mock_sessions) conn = _new_state_db(mock_home / "state.db") try: seed_ts = time.time() - 10 conn.execute( "INSERT INTO sessions (id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) " "VALUES (?, 'telegram', 'Messaging Session', 'openai/gpt-5', ?, 0, NULL, NULL, NULL)", (sid, seed_ts), ) conn.commit() session = models.Session( session_id=sid, title="Imported Messaging Session", workspace=str(tmp_path), messages=[{"role": "user", "content": "Need help", "timestamp": 1.0}], ) session.is_cli_session = True session.session_source = "messaging" session.source_tag = "telegram" session.raw_source = "telegram" session.source_label = "Telegram" session.save(touch_updated_at=False) routes._persist_handoff_summary(sid, "Please handoff after context", "telegram", 2, False) saved = models.Session.load(sid) assert len(saved.messages) == 2 marker = saved.messages[-1] assert marker.get("name") == "handoff_summary" marker_payload = _extract_handoff_marker_payload(marker) assert marker_payload is not None assert marker_payload.get("session_id") == sid assert marker_payload.get("summary") == "Please handoff after context" assert marker_payload.get("channel") == "telegram" assert marker_payload.get("rounds") == 2 rows = conn.execute( "SELECT role, content FROM messages WHERE session_id = ? ORDER BY rowid ASC", (sid,), ).fetchall() assert len(rows) == 1 assert rows[0][0] == "tool" db_payload = _extract_handoff_marker_payload({"content": rows[0][1]}) assert db_payload is not None assert db_payload.get("session_id") == sid assert db_payload.get("summary") == "Please handoff after context" finally: conn.close() def test_persisted_handoff_summary_deduplicates_identical_tail_markers(tmp_path, monkeypatch): """When the tail already contains the same handoff marker, repeated generation should be idempotent.""" import api.routes as routes import api.models as models import api.profiles as profiles sid = "messaging_1013_dedupe_tail" mock_home = tmp_path / "hermes_home" mock_home.mkdir() mock_sessions = tmp_path / "sessions" mock_sessions.mkdir() monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: mock_home) monkeypatch.setattr(models, "SESSION_DIR", mock_sessions) conn = _new_state_db(mock_home / "state.db") try: baseline = time.time() conn.execute( "INSERT INTO sessions (id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) " "VALUES (?, 'telegram', 'Messaging Session', 'openai/gpt-5', ?, 1, NULL, NULL, NULL)", (sid, baseline), ) conn.commit() marker = routes._build_handoff_summary_tool_message(sid, "Repeat me", "telegram", 3, False) session = models.Session( session_id=sid, title="Imported Messaging Session", workspace=str(tmp_path), messages=[ {"role": "user", "content": "Need help", "timestamp": baseline - 1}, marker, ], ) session.is_cli_session = True session.session_source = "messaging" session.source_tag = "telegram" session.raw_source = "telegram" session.source_label = "Telegram" session.save(touch_updated_at=False) conn.execute( "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'tool', ?, ?)", (sid, marker["content"], marker["timestamp"]), ) conn.commit() routes._persist_handoff_summary(sid, "Repeat me", "telegram", 3, False) refreshed = models.Session.load(sid) assert len(refreshed.messages) == 2 rows = conn.execute( "SELECT content FROM messages WHERE session_id = ? ORDER BY rowid ASC", (sid,), ).fetchall() assert len(rows) == 1 assert _extract_handoff_marker_payload({"content": rows[0][0]}) is not None finally: conn.close() def test_persist_handoff_summary_falls_back_when_local_session_file_missing(tmp_path, monkeypatch): """Messaging session IDs should still persist to state.db when no local WebUI session exists.""" import api.routes as routes import api.profiles as profiles sid = "messaging_1013_no_local_file" mock_home = tmp_path / "hermes_home" mock_home.mkdir() monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: mock_home) conn = _new_state_db(mock_home / "state.db") # Force messaging classification while keeping the local shell absent. monkeypatch.setattr(routes, "_is_messaging_session_id", lambda _sid: True) try: routes._persist_handoff_summary(sid, "Persist without local shell", "telegram", 1, True) rows = conn.execute( "SELECT role, content FROM messages WHERE session_id = ? ORDER BY rowid ASC", (sid,), ).fetchall() assert len(rows) == 1 assert rows[0][0] == "tool" payload = _extract_handoff_marker_payload({"content": rows[0][1]}) assert payload is not None assert payload.get("session_id") == sid assert payload.get("fallback") is True finally: conn.close()