{"repo":"NousResearch/hermes-agent","public_id":"18765","display_label":"Fix regressions from lazy session creation changes","summary":"These issues and pull requests address regressions introduced by a lazy session creation change, specifically cases where silent failures in database session handling caused response content to appear as zero characters.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":20363,"merged_at":"2026-05-05T19:41:50+00:00"},"resolution":{"state":"resolved","by":"pr-20363"},"freshness":{"state":"verified","last_verified_at":"2026-05-05T19:41:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":18765,"pr_number":20363,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-05T19:41:50+00:00"},{"issue_number":20001,"pr_number":20363,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-05T19:41:50+00:00"}],"members":[{"kind":"issue","number":18765,"title":"fix: response=0 chars regression in gateway after lazy session creation commit","url":null,"state":"closed"},{"kind":"pr","number":18798,"title":"fix: prevent response=0 chars regression from silent _ensure_db_session failures (#18765)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#27461","number":27461}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":20363,"title":"fix: resolve lazy session creation regressions (#18370 fallout)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","hermes_state.py","tests/test_lazy_session_regressions.py","tests/test_tui_gateway_server.py","tui_gateway/server.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -940,6 +940,18 @@ def _run_state_db_auto_maintenance(session_db) -> None:\n         except Exception as _prune_exc:\n             logger.debug(\"Ghost session prune skipped: %s\", _prune_exc)\n \n+        # One-time finalize of orphaned compression continuations (#20001).\n+        try:\n+            if not session_db.get_meta(\"orphaned_compression_finalize_v1\"):\n+                finalized = session_db.finalize_orphaned_compression_sessions()\n+                session_db.set_meta(\"orphaned_compression_finalize_v1\", \"1\")\n+                if finalized:\n+                    logger.info(\n+                        \"Finalized %d orphaned compression sessions\", finalized\n+                    )\n+        except Exception as _finalize_exc:\n+            logger.debug(\"Orphan compression finalize skipped: %s\", _finalize_exc)\n+\n         cfg = (_load_full_config().get(\"sessions\") or {})\n         if not cfg.get(\"auto_prune\", False):\n             return"]},{"file":"gateway/run.py","hunks":["@@ -939,6 +939,52 @@ def _format_gateway_process_notification(evt: dict) -> \"str | None\":\n _gateway_runner_ref: _weakref.ref = lambda: None\n \n \n+def _normalize_empty_agent_response(\n+    agent_result: dict,\n+    response: str,\n+    *,\n+    history_len: int = 0,\n+) -> str:\n+    \"\"\"Normalize empty/None agent responses into user-facing messages.\n+\n+    Consolidates the existing ``failed`` handler and adds a catch-all for\n+    the case where the agent did work (api_calls > 0) but returned no text.\n+    Fix for #18765.\n+    \"\"\"\n+    if response:\n+        return response\n+\n+    if agent_result.get(\"failed\"):\n+        error_detail = agent_result.get(\"error\", \"unknown error\")\n+        error_str = str(error_detail).lower()\n+        is_context_failure = any(\n+            p in error_str\n+            for p in (\"context\", \"token\", \"too large\", \"too long\", \"exceed\", \"payload\")\n+        ) or (\"400\" in error_str and history_len > 50)\n+        if is_context_failure:\n+            return (\n+                \"⚠️ Session too large for the model's context window.\\n\"\n+                \"Use /compact to compress the conversation, or \"\n+                \"/reset to start fresh.\"\n+            )\n+        return (\n+            f\"The request failed: {str(error_detail)[:300]}\\n\"\n+            \"Try again or use /reset to start a fresh session.\"\n+        )\n+\n+    api_calls = int(agent_result.get(\"api_calls\", 0) or 0)\n+    if api_calls > 0 and not agent_result.get(\"interrupted\"):\n+        if agent_result.get(\"partial\"):\n+            err = agent_result.get(\"error\", \"processing incomplete\")\n+            return f\"⚠️ Processing stopped: {str(err)[:200]}. Try again.\"\n+        return (\n+            \"⚠️ Processing completed but no response was generated. \"\n+            \"This may be a transient error — try sending your message again.\"\n+        )\n+\n+    return response\n+\n+\n class GatewayRunner:\n     \"\"\"\n     Main gateway controller.","@@ -6439,33 +6485,11 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                         session_key, _e,\n                     )\n \n-            # Surface error details when the agent failed silently (final_response=None)\n-            if not response and agent_result.get(\"failed\"):\n-                error_detail = agent_result.get(\"error\", \"unknown error\")\n-                error_str = str(error_detail).lower()\n-\n-                # Detect context-overflow failures and give specific guidance.\n-                # Generic 400 \"Error\" from Anthropic with large sessions is the\n-                # most common cause of this (#1630).\n-                _is_ctx_fail = any(p in error_str for p in (\n-                    \"context\", \"token\", \"too large\", \"too long\",\n-                    \"exceed\", \"payload\",\n-                )) or (\n-                    \"400\" in error_str\n-                    and len(history) > 50\n-                )\n-\n-                if _is_ctx_fail:\n-                    response = (\n-                        \"⚠️ Session too large for the model's context window.\\n\"\n-                        \"Use /compact to compress the conversation, or \"\n-                        \"/reset to start fresh.\"\n-                    )\n-                else:\n-                    response = (\n-                        f\"The request failed: {str(error_detail)[:300]}\\n\"\n-                        \"Try again or use /reset to start a fresh session.\"\n-                    )\n+            # Normalize empty responses: surface errors, partial failures, and\n+            # the case where agent did work but returned no text. Fix for #18765.\n+            response = _normalize_empty_agent_response(\n+                agent_result, response, history_len=len(history),\n+            )\n \n             # If the agent's session_id changed during compression, update\n             # session_entry so transcript writes below go to the right session."]},{"file":"hermes_state.py","hunks":["@@ -718,6 +718,45 @@ def _do(conn):\n                 self._remove_session_files(sessions_dir, sid)\n         return len(removed_ids)\n \n+    def finalize_orphaned_compression_sessions(self) -> int:\n+        \"\"\"Mark orphaned compression continuation sessions as ended.\n+\n+        Targets child sessions that were never finalized: parent is ended\n+        with reason='compression', child has messages but no end_reason/ended_at\n+        and api_call_count=0.  Non-destructive: preserves all messages and sets\n+        end_reason='orphaned_compression'.  Fix for #20001.\n+        \"\"\"\n+        cutoff = time.time() - 604800  # 7 days\n+\n+        def _do(conn):\n+            now = time.time()\n+            result = conn.execute(\n+                \"\"\"\n+                UPDATE sessions\n+                SET ended_at = ?,\n+                    end_reason = 'orphaned_compression'\n+                WHERE api_call_count = 0\n+                  AND end_reason IS NULL\n+                  AND ended_at IS NULL\n+                  AND started_at < ?\n+                  AND parent_session_id IS NOT NULL\n+                  AND EXISTS (\n+                      SELECT 1 FROM sessions p\n+                      WHERE p.id = sessions.parent_session_id\n+                        AND p.end_reason = 'compression'\n+                        AND p.ended_at IS NOT NULL\n+                  )\n+                  AND EXISTS (\n+                      SELECT 1 FROM messages m\n+                      WHERE m.session_id = sessions.id\n+                  )\n+                \"\"\",\n+                (now, cutoff),\n+            )\n+            return result.rowcount\n+\n+        return self._execute_write(_do) or 0\n+\n     def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:\n         \"\"\"Get a session by ID.\"\"\"\n         with self._lock:"]},{"file":"tests/test_lazy_session_regressions.py","hunks":["@@ -0,0 +1,608 @@\n+\"\"\"Reproduction tests for #18370 fallout: lazy session creation regressions.\n+\n+Tests cover:\n+1. Bug #20001 — _finalize_session() uses stale session_key after compression rotation\n+2. Bug #20001 — _sync_session_key_after_compress called post-run_conversation\n+3. Bug #19029 — pending_title ValueError leaves title wedged\n+4. Bug #18765 — gateway surfaces null response when agent did work\n+5. Prune — finalize_orphaned_compression_sessions catches ghost continuations\n+\"\"\"\n+\n+import threading\n+import time\n+import types\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+# ===========================================================================\n+# Helpers\n+# ===========================================================================\n+\n+def _make_session_db(tmp_path):\n+    \"\"\"Create a real SessionDB for integration-style tests.\"\"\"\n+    from hermes_state import SessionDB\n+    db_path = tmp_path / \"test_state.db\"\n+    return SessionDB(db_path=db_path)\n+\n+\n+def _tui_session(agent=None, session_key=\"session-key-old\", **extra):\n+    \"\"\"Minimal TUI gateway session dict matching server._sessions values.\"\"\"\n+    return {\n+        \"agent\": agent if agent is not None else types.SimpleNamespace(session_id=session_key),\n+        \"session_key\": session_key,\n+        \"history\": [],\n+        \"history_lock\": threading.Lock(),\n+        \"history_version\": 0,\n+        \"running\": False,\n+        \"attached_images\": [],\n+        \"image_counter\": 0,\n+        \"cols\": 80,\n+        \"slash_worker\": None,\n+        \"show_reasoning\": False,\n+        \"tool_progress_mode\": \"all\",\n+        \"pending_title\": None,\n+        **extra,\n+    }\n+\n+\n+# ===========================================================================\n+# Bug #20001: _finalize_session uses stale session_key\n+# ===========================================================================\n+\n+class TestFinalizeSessionUsesAgentSessionId:\n+    \"\"\"After compression rotates agent.session_id, _finalize_session()\n+    must call end_session() on the NEW (current) session_id, not the stale\n+    session_key stored in the session dict.\"\"\"\n+\n+    def test_finalize_targets_agent_session_id_not_stale_key(self, tmp_path):\n+        \"\"\"Reproduction: agent.session_id rotated by compression, but\n+        session['session_key'] still holds old value. _finalize_session()\n+        should end the agent's current session.\"\"\"\n+        from tui_gateway import server\n+\n+        db = _make_session_db(tmp_path)\n+\n+        # Create two sessions: parent (already ended by compression) and continuation\n+        db.create_session(session_id=\"parent-session\", source=\"tui\", model=\"test\")\n+        db.end_session(\"parent-session\", \"compression\")\n+\n+        db.create_session(\n+            session_id=\"continuation-session\",\n+            source=\"tui\",\n+            model=\"test\",\n+            parent_session_id=\"parent-session\",\n+        )\n+        # Continuation is NOT ended — this is the bug state\n+\n+        # Agent has rotated to continuation session\n+        agent = types.SimpleNamespace(\n+            session_id=\"continuation-session\",\n+            commit_memory_session=lambda h: None,\n+        )\n+\n+        # Session dict still holds stale key (the bug condition)\n+        session = _tui_session(\n+            agent=agent,\n+            session_key=\"parent-session\",\n+            history=[{\"role\": \"user\", \"content\": \"hello\"}],\n+        )\n+\n+        # Monkeypatch _get_db to return our test DB\n+        with patch.object(server, \"_get_db\", return_value=db):\n+            with patch.object(server, \"_notify_session_boundary\", lambda *a: None):\n+                server._finalize_session(session, end_reason=\"tui_close\")\n+\n+        # The continuation session should be ended\n+        continuation = db.get_session(\"continuation-session\")\n+        assert continuation[\"ended_at\"] is not None, (\n+            \"_finalize_session should end the agent's current session (continuation), \"\n+            \"not the already-ended parent\"\n+        )\n+        assert continuation[\"end_reason\"] == \"tui_close\"\n+\n+    def test_finalize_fallback_to_session_key_when_agent_is_none(self, tmp_path):\n+        \"\"\"When agent is None (e.g. session never fully initialized),\n+        _finalize_session falls back to session_key.\"\"\"\n+        from tui_gateway import server\n+\n+        db = _make_session_db(tmp_path)\n+        db.create_session(session_id=\"orphan-key\", source=\"tui\", model=\"test\")\n+\n+        session = _tui_session(agent=None, session_key=\"orphan-key\")\n+\n+        with patch.object(server, \"_get_db\", return_value=db):\n+            with patch.object(server, \"_notify_session_boundary\", lambda *a: None):\n+                server._finalize_session(session, end_reason=\"tui_close\")\n+\n+        row = db.get_session(\"orphan-key\")\n+        assert row[\"ended_at\"] is not None\n+        assert row[\"end_reason\"] == \"tui_close\"\n+\n+\n+# ===========================================================================\n+# Bug #20001: _sync_session_key_after_compress post-run_conversation\n+# ===========================================================================\n+\n+class TestSyncSessionKeyAfterAutoCompress:\n+    \"\"\"When auto-compression fires inside run_conversation(), the post-turn\n+    code in _run_prompt_submit must call _sync_session_key_after_compress\n+    to update session_key for downstream consumers (title, goals, etc.).\"\"\"\n+\n+    def test_session_key_synced_after_run_conversation_with_compression(self, monkeypatch):\n+        \"\"\"Simulate: run_conversation() internally compresses and rotates\n+        agent.session_id. After it returns, session['session_key'] must match.\"\"\"\n+        from tui_gateway import server\n+\n+        class _CompressingAgent:\n+            \"\"\"Agent that simulates compression-driven session_id rotation.\"\"\"\n+            def __init__(self):\n+                self.session_id = \"pre-compress-key\"\n+                self._cached_system_prompt = \"\"\n+\n+            def run_conversation(self, prompt, conversation_history=None, stream_callback=None):\n+                # Simulate what _compress_context does: rotate session_id\n+                self.session_id = \"post-compress-key\"\n+                return {\n+                    \"final_response\": \"done\",\n+                    \"messages\": [\n+                        {\"role\": \"user\", \"content\": prompt},\n+                        {\"role\": \"assistant\", \"content\": \"done\"},\n+                    ],\n+                }\n+\n+        agent = _CompressingAgent()\n+        session = _tui_session(agent=agent, session_key=\"pre-compress-key\")\n+\n+        # Track if _sync_session_key_after_compress was called\n+        sync_calls = []\n+        original_sync = server._sync_session_key_after_compress\n+\n+        def _tracking_sync(sid, sess, **kwargs):\n+            sync_calls.append((sid, sess.get(\"session_key\")))\n+            # Just update the key directly (skip approval routing etc.)\n+            new_id = getattr(sess.get(\"agent\"), \"session_id\", None) or \"\"\n+            if new_id and new_id != sess.get(\"session_key\"):\n+                sess[\"session_key\"] = new_id\n+\n+        monkeypatch.setattr(server, \"_sync_session_key_after_compress\", _tracking_sync)\n+        monkeypatch.setattr(server, \"_emit\", lambda *a, **kw: None)\n+        monkeypatch.setattr(server, \"make_stream_renderer\", lambda cols: None)\n+        monkeypatch.setattr(server, \"render_message\", lambda raw, cols: None)\n+\n+        # Use _ImmediateThread pattern to run synchronously\n+        class _ImmediateThread:\n+            def __init__(self, target=None, daemon=None, **kw):\n+                self._target = target\n+            def start(self):\n+                self._target()\n+\n+        server._sessions[\"test-sid\"] = session\n+        monkeypatch.setattr(server.threading, \"Thread\", _ImmediateThread)\n+\n+        try:\n+            server.handle_request({\n+                \"id\": \"1\",\n+                \"method\": \"prompt.submit\",\n+                \"params\": {\"session_id\": \"test-sid\", \"text\": \"hello\"},\n+            })\n+\n+            # Sync should have been called\n+            assert len(sync_calls) > 0, (\n+                \"_sync_session_key_after_compress must be called after run_conversation \"\n+                \"to pick up compression-driven session_id rotation\"\n+            )\n+\n+            # session_key should now match agent.session_id\n+            assert session[\"session_key\"] == \"post-compress-key\", (\n+                \"session_key must be updated to match agent.session_id after compression\"\n+            )\n+        finally:\n+            server._sessions.pop(\"test-sid\", None)\n+\n+\n+# ===========================================================================\n+# Bug #19029: pending_title ValueError wedge\n+# ===========================================================================\n+\n+class TestPendingTitleValueError:\n+    \"\"\"When set_session_title raises ValueError (duplicate/invalid title),\n+    pending_title must be cleared — not left wedged forever.\"\"\"\n+\n+    def test_valueerror_clears_pending_title(self, monkeypatch):\n+        \"\"\"ValueError from set_session_title should drop pending_title.\"\"\"\n+        from tui_gateway import server\n+\n+        mock_db = MagicMock()\n+        mock_db.set_session_title.side_effect = ValueError(\"duplicate title\")\n+\n+        class _Agent:\n+            session_id = \"test-session\"\n+            _cached_system_prompt = \"\"\n+            def run_conversation(self, prompt, **kw):\n+                return {\n+                    \"final_response\": \"ok\",\n+                    \"messages\": [{\"role\": \"assistant\", \"content\": \"ok\"}],\n+                }\n+\n+        session = _tui_session(\n+            agent=_Agent(),\n+            session_key=\"test-session\",\n+            pending_title=\"My Title\",\n+        )\n+\n+        monkeypatch.setattr(server, \"_get_db\", lambda: mock_db)\n+        monkeypatch.setattr(server, \"_emit\", lambda *a, **kw: None)\n+        monkeypatch.setattr(server, \"make_stream_renderer\", lambda cols: None)\n+        monkeypatch.setattr(server, \"render_message\", lambda raw, cols: None)\n+        monkeypatch.setattr(\n+            server, \"_sync_session_key_after_compress\", lambda *a, **kw: None\n+        )\n+\n+        class _ImmediateThread:\n+            def __init__(self, target=None, daemon=None, **kw):\n+                self._target = target\n+            def start(self):\n+                self._target()\n+\n+        server._sessions[\"sid\"] = session\n+        monkeypatch.setattr(server.threading, \"Thread\", _ImmediateThread)\n+\n+        try:\n+            server.handle_request({\n+                \"id\": \"1\",\n+                \"method\": \"prompt.submit\",\n+                \"params\": {\"session_id\": \"sid\", \"text\": \"hello\"},\n+            })\n+\n+            # pending_title should be cleared on ValueError, not left wedged\n+            assert session.get(\"pending_title\") is None, (\n+                \"ValueError from set_session_title must clear pending_title \"\n+                \"so auto-title can take over\"\n+            )\n+        finally:\n+            server._sessions.pop(\"sid\", None)\n+\n+    def test_other_exception_keeps_pending_title_for_retry(self, monkeypatch):\n+        \"\"\"Non-ValueError exceptions should keep pending_title for retry.\"\"\"\n+        from tui_gateway import server\n+\n+        mock_db = MagicMock()\n+        mock_db.set_session_title.side_effect = RuntimeError(\"transient DB lock\")\n+\n+        class _Agent:\n+            session_id = \"test-session\"\n+            _cached_system_prompt = \"\"\n+            def run_conversation(self, prompt, **kw):\n+                return {\n+                    \"final_response\": \"ok\",\n+                    \"messages\": [{\"role\": \"assistant\", \"content\": \"ok\"}],\n+                }\n+\n+        session = _tui_session(\n+            agent=_Agent(),\n+            session_key=\"test-session\",\n+            pending_title=\"My Title\",\n+        )\n+\n+        monkeypatch.setattr(server, \"_get_db\", lambda: mock_db)\n+        monkeypatch.setattr(server, \"_emit\", lambda *a, **kw: None)\n+        monkeypatch.setattr(server, \"make_stream_renderer\", lambda cols: None)\n+        monkeypatch.setattr(server, \"render_message\", lambda raw, cols: None)\n+        monkeypatch.setattr(\n+            server, \"_sync_session_key_after_compress\", lambda *a, **kw: None\n+        )\n+\n+        class _ImmediateThread:\n+            def __init__(self, target=None, daemon=None, **kw):\n+                self._target = target\n+            def start(self):\n+                self._target()\n+\n+        server._sessions[\"sid\"] = session\n+        monkeypatch.setattr(server.threading, \"Thread\", _ImmediateThread)\n+\n+        try:\n+            server.handle_request({\n+                \"id\": \"1\",\n+                \"method\": \"prompt.submit\",\n+                \"params\": {\"session_id\": \"sid\", \"text\": \"hello\"},\n+            })\n+\n+            # Non-ValueError should keep pending_title for retry\n+            assert session.get(\"pending_title\") == \"My Title\", (\n+                \"Non-ValueError exceptions should keep pending_title intact \"\n+                \"for retry on next turn\"\n+            )\n+        finally:\n+            server._sessions.pop(\"sid\", None)\n+\n+\n+# ===========================================================================\n+# Bug #18765: Gateway surfaces null response\n+# ===========================================================================\n+\n+class TestGatewaySurfacesNullResponse:\n+    \"\"\"When the agent does work (api_calls > 0) but returns no final_response,\n+    the gateway must surface an error to the user instead of silently sending\n+    nothing. Tests exercise the production _normalize_empty_agent_response helper.\"\"\"\n+\n+    def test_partial_response_surfaces_error(self):\n+        \"\"\"Agent returns partial=True with no response → user sees error.\"\"\"\n+        from gateway.run import _normalize_empty_agent_response\n+\n+        agent_result = {\n+            \"final_response\": None,\n+            \"api_calls\": 5,\n+            \"partial\": True,\n+            \"interrupted\": False,\n+            \"error\": \"Model generated invalid tool call: nonexistent_tool\",\n+        }\n+\n+        response = agent_result.get(\"final_response\") or \"\"\n+        response = _normalize_empty_agent_response(\n+            agent_result, response, history_len=10,\n+        )\n+\n+        assert response != \"\", \"Null response with api_calls>0 must be surfaced\"\n+        assert \"nonexistent_tool\" in response\n+\n+    def test_interrupted_response_stays_empty(self):\n+        \"\"\"Interrupted agent → response stays empty (platform handles UX).\"\"\"\n+        from gateway.run import _normalize_empty_agent_response\n+\n+        agent_result = {\n+            \"final_response\": None,\n+            \"api_calls\": 3,\n+            \"partial\": False,\n+            \"interrupted\": True,\n+        }\n+\n+        response = agent_result.get(\"final_response\") or \"\"\n+        response = _normalize_empty_agent_response(\n+            agent_result, response, history_len=10,\n+        )\n+\n+        assert response == \"\", \"Interrupted turns should not get synthetic responses\"\n+\n+    def test_failed_context_overflow(self):\n+        \"\"\"Agent failed with context overflow → specific guidance message.\"\"\"\n+        from gateway.run import _normalize_empty_agent_response\n+\n+        agent_result = {\n+            \"final_response\": None,\n+            \"api_calls\": 0,\n+            \"failed\": True,\n+            \"error\": \"400 Bad Request: context length exceeded\",\n+        }\n+\n+        response = agent_result.get(\"final_response\") or \"\"\n+        response = _normalize_empty_agent_response(\n+            agent_result, response, history_len=60,\n+        )\n+\n+        assert \"context window\" in response\n+        assert \"/compact\" in response\n+\n+    def test_failed_generic_error(self):\n+        \"\"\"Agent failed with non-context error → generic error message.\"\"\"\n+        from gateway.run import _normalize_empty_agent_response\n+\n+        agent_result = {\n+            \"final_response\": None,\n+            \"api_calls\": 0,\n+            \"failed\": True,\n+            \"error\": \"500 Internal Server Error\",\n+        }\n+\n+        response = agent_result.get(\"final_response\") or \"\"\n+        response = _normalize_empty_agent_response(\n+            agent_result, response, history_len=5,\n+        )\n+\n+        assert \"500 Internal Server Error\" in response\n+        assert \"/reset\" in response\n+\n+    def test_nonempty_response_passes_through(self):\n+        \"\"\"Non-empty response is returned unchanged.\"\"\"\n+        from gateway.run import _normalize_empty_agent_response\n+\n+        agent_result = {\"final_response\": \"Hello!\", \"api_calls\": 1}\n+        response = \"Hello!\"\n+        result = _normalize_empty_agent_response(\n+            agent_result, response, history_len=5,\n+        )\n+\n+        assert result == \"Hello!\"\n+\n+\n+# ===========================================================================\n+# Prune: finalize_orphaned_compression_sessions\n+# ===========================================================================\n+\n+class TestFinalizeOrphanedCompressionSessions:\n+    \"\"\"The prune migration marks ghost compression continuations as ended.\"\"\"\n+\n+    def test_marks_ghost_continuation_with_compression_parent(self, tmp_path):\n+        \"\"\"Ghost session with compression-ended parent + messages → finalized.\"\"\"\n+        db = _make_session_db(tmp_path)\n+\n+        # Parent session (ended by compression — this is the key condition)\n+        db.create_session(session_id=\"parent\", source=\"tui\", model=\"test\")\n+        db.end_session(\"parent\", \"compression\")\n+\n+        # Ghost continuation (has messages, never finalized)\n+        db.create_session(\n+            session_id=\"ghost-cont\",\n+            source=\"tui\",\n+            model=\"test\",\n+            parent_session_id=\"parent\",\n+        )\n+        db.append_message(\"ghost-cont\", role=\"user\", content=\"hello\")\n+        db.append_message(\"ghost-cont\", role=\"assistant\", content=\"hi\")\n+\n+        # Make it old enough (fake started_at)\n+        db._execute_write(\n+            lambda conn: conn.execute(\n+                \"UPDATE sessions SET started_at = ? WHERE id = ?\",\n+                (time.time() - 800000, \"ghost-cont\"),  # ~9 days old\n+            )\n+        )\n+\n+        count = db.finalize_orphaned_compression_sessions()\n+        assert count == 1\n+\n+        session = db.get_session(\"ghost-cont\")\n+        assert session[\"ended_at\"] is not None\n+        assert session[\"end_reason\"] == \"orphaned_compression\"\n+\n+    def test_skips_session_without_parent(self, tmp_path):\n+        \"\"\"Ghost session without parent_session_id is NOT a compression\n+        continuation — should not be touched by this prune.\"\"\"\n+        db = _make_session_db(tmp_path)\n+\n+        db.create_session(session_id=\"ghost-notitle\", source=\"tui\", model=\"test\")\n+        db.append_message(\"ghost-notitle\", role=\"user\", content=\"test\")\n+\n+        db._execute_write(\n+            lambda conn: conn.execute(\n+                \"UPDATE sessions SET started_at = ? WHERE id = ?\",\n+                (time.time() - 800000, \"ghost-notitle\"),\n+            )\n+        )\n+\n+        count = db.finalize_orphaned_compression_sessions()\n+        assert count == 0\n+\n+    def test_skips_recent_sessions(self, tmp_path):\n+        \"\"\"Sessions younger than 7 days are not touched.\"\"\"\n+        db = _make_session_db(tmp_path)\n+\n+        # Create parent first to satisfy FK constraint\n+        db.create_session(session_id=\"some-parent\", source=\"tui\", model=\"test\")\n+        db.create_session(\n+            session_id=\"recent\",\n+            source=\"tui\",\n+            model=\"test\",\n+            parent_session_id=\"some-parent\",\n+        )\n+        db.append_message(\"recent\", role=\"user\", content=\"hello\")\n+        # started_at is now() — within 7 days\n+\n+        count = db.finalize_orphaned_compression_sessions()\n+        assert count == 0\n+\n+    def test_skips_sessions_with_end_reason(self, tmp_path):\n+        \"\"\"Properly finalized sessions (even without api_call_count) are skipped.\"\"\"\n+        db = _make_session_db(tmp_path)\n+\n+        # Create parent first to satisfy FK constraint\n+        db.create_session(session_id=\"parent\", source=\"tui\", model=\"test\")\n+        db.end_session(\"parent\", \"compression\")\n+\n+        db.create_session(\n+            session_id=\"already-ended\",\n+            source=\"tui\",\n+            model=\"test\",\n+            parent_session_id=\"parent\",\n+        )\n+        db.append_message(\"already-ended\", role=\"user\", content=\"hello\")\n+        db.end_session(\"already-ended\", \"user_exit\")\n+\n+        db._execute_write(\n+            lambda conn: conn.execute(\n+                \"UPDATE sessions SET started_at = ? WHERE id = ?\",\n+                (time.time() - 800000, \"already-ended\"),\n+            )\n+        )\n+\n+        count = db.finalize_orphaned_compression_sessions()\n+        assert count == 0\n+\n+    def test_skips_session_with_non_compression_parent(self, tmp_path):\n+        \"\"\"Child session whose parent was NOT ended by compression should\n+        not be touched — it's not from the compression continuation path.\"\"\"\n+        db = _make_session_db(tmp_path)\n+\n+        # Parent ended by user_exit, not compression\n+        db.create_session(session_id=\"parent\", source=\"tui\", model=\"test\")\n+        db.end_session(\"parent\", \"user_exit\")\n+\n+        db.create_session(\n+            session_id=\"child\",\n+            source=\"tui\",\n+            model=\"test\",\n+            parent_session_id=\"parent\",\n+        )\n+        db.append_message(\"child\", role=\"user\", content=\"hello\")\n+\n+        db._execute_write(\n+            lambda conn: conn.execute(\n+                \"UPDATE sessions SET started_at = ? WHERE id = ?\",\n+                (time.time() - 800000, \"child\"),\n+            )\n+        )\n+\n+        count = db.finalize_orphaned_compression_sessions()\n+        assert count == 0\n+\n+    def test_skips_sessions_without_messages(self, tmp_path):\n+        \"\"\"Empty sessions (no messages) are NOT targeted by this prune —\n+        those are handled by prune_empty_ghost_sessions().\"\"\"\n+        db = _make_session_db(tmp_path)\n+\n+        # Create parent first to satisfy FK constraint\n+        db.create_session(session_id=\"parent\", source=\"tui\", model=\"test\")\n+        db.end_session(\"parent\", \"compression\")\n+\n+        db.create_session(\n+            session_id=\"empty-ghost\",\n+            source=\"tui\",\n+            model=\"test\",\n+            parent_session_id=\"parent\",\n+        )\n+        # No messages appended\n+\n+        db._execute_write(\n+            lambda conn: conn.execute(\n+                \"UPDATE sessions SET started_at = ? WHERE id = ?\",\n+                (time.time() - 800000, \"empty-ghost\"),\n+            )\n+        )\n+\n+        count = db.finalize_orphaned_compression_sessions()\n+        assert count == 0\n+\n+    def test_titled_ghost_with_parent_is_caught(self, tmp_path):\n+        \"\"\"Ghost continuation that HAS a title (propagated from parent by\n+        _compress_context) is still caught via parent with end_reason='compression'.\"\"\"\n+        db = _make_session_db(tmp_path)\n+\n+        # Create parent first — ended by compression\n+        db.create_session(session_id=\"parent\", source=\"tui\", model=\"test\")\n+        db.set_session_title(\"parent\", \"Chat\")\n+        db.end_session(\"parent\", \"compression\")\n+\n+        db.create_session(\n+            session_id=\"titled-ghost\",\n+            source=\"tui\",\n+            model=\"test\",\n+            parent_session_id=\"parent\",\n+        )\n+        db.set_session_title(\"titled-ghost\", \"Chat (2)\")\n+        db.append_message(\"titled-ghost\", role=\"user\", content=\"continued...\")\n+\n+        db._execute_write(\n+            lambda conn: conn.execute(\n+                \"UPDATE sessions SET started_at = ? WHERE id = ?\",\n+                (time.time() - 800000, \"titled-ghost\"),\n+            )\n+        )\n+\n+        count = db.finalize_orphaned_compression_sessions()\n+        assert count == 1\n+\n+        session = db.get_session(\"titled-ghost\")\n+        assert session[\"end_reason\"] == \"orphaned_compression\""]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -921,56 +921,70 @@ def set_session_title(self, _key, _title):\n \n \n def test_session_create_drops_pending_title_on_valueerror(monkeypatch):\n-    unblock_agent = threading.Event()\n-\n-    class _FakeWorker:\n-        def __init__(self, key, model):\n-            self.key = key\n-\n-        def close(self):\n-            return None\n+    \"\"\"When set_session_title raises ValueError during post-message title flush,\n+    pending_title should be dropped (non-retryable). Updated for post-#18370\n+    lazy session creation where title is applied post-first-message.\n+    \"\"\"\n \n-    class _FakeAgent:\n+    class _Agent:\n+        session_id = \"test-session\"\n         model = \"x\"\n         provider = \"openrouter\"\n         base_url = \"\"\n         api_key = \"\"\n+        _cached_system_prompt = \"\"\n \n-    class _FakeDB:\n-        def create_session(self, _key, source=\"tui\", model=None):\n-            return None\n+        def run_conversation(self, prompt, **kw):\n+            return {\n+                \"final_response\": \"ok\",\n+                \"messages\": [{\"role\": \"assistant\", \"content\": \"ok\"}],\n+            }\n \n+    class _FakeDB:\n         def set_session_title(self, _key, _title):\n             raise ValueError(\"Title already in use\")\n \n-    def _make_agent(_sid, _key):\n-        unblock_agent.wait(timeout=2.0)\n-        return _FakeAgent()\n-\n-    monkeypatch.setattr(server, \"_make_agent\", _make_agent)\n-    monkeypatch.setattr(server, \"_SlashWorker\", _FakeWorker)\n-    monkeypatch.setattr(server, \"_get_db\", lambda: _FakeDB())\n-    monkeypatch.setattr(server, \"_session_info\", lambda _a: {\"model\": \"x\"})\n-    monkeypatch.setattr(server, \"_probe_credentials\", lambda _a: None)\n-    monkeypatch.setattr(server, \"_wire_callbacks\", lambda _sid: None)\n-    monkeypatch.setattr(server, \"_emit\", lambda *a, **kw: None)\n+    class _ImmediateThread:\n+        def __init__(self, target=None, daemon=None, **kw):\n+            self._target = target\n \n-    import tools.approval as _approval\n+        def start(self):\n+            self._target()\n \n-    monkeypatch.setattr(_approval, \"register_gateway_notify\", lambda key, cb: None)\n-    monkeypatch.setattr(_approval, \"load_permanent_allowlist\", lambda: None)\n+    agent = _Agent()\n+    session = {\n+        \"agent\": agent,\n+        \"session_key\": \"test-session\",\n+        \"history\": [],\n+        \"history_lock\": threading.Lock(),\n+        \"history_version\": 0,\n+        \"running\": False,\n+        \"attached_images\": [],\n+        \"image_counter\": 0,\n+        \"cols\": 80,\n+        \"slash_worker\": None,\n+        \"show_reasoning\": False,\n+        \"tool_progress_mode\": \"all\",\n+        \"pending_title\": \"duplicate title\",\n+    }\n \n-    resp = server.handle_request(\n-        {\"id\": \"1\", \"method\": \"session.create\", \"params\": {\"cols\": 80}}\n+    server._sessions[\"sid\"] = session\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _FakeDB())\n+    monkeypatch.setattr(server, \"_emit\", lambda *a, **kw: None)\n+    monkeypatch.setattr(server, \"make_stream_renderer\", lambda cols: None)\n+    monkeypatch.setattr(server, \"render_message\", lambda raw, cols: None)\n+    monkeypatch.setattr(\n+        server, \"_sync_session_key_after_compress\", lambda *a, **kw: None\n     )\n-    sid = resp[\"result\"][\"session_id\"]\n-    session = server._sessions[sid]\n-    session[\"pending_title\"] = \"duplicate title\"\n-    unblock_agent.set()\n-    session[\"agent_ready\"].wait(timeout=2.0)\n+    monkeypatch.setattr(server.threading, \"Thread\", _ImmediateThread)\n \n-    assert session[\"pending_title\"] is None\n-    server._sessions.pop(sid, None)\n+    try:\n+        server.handle_request(\n+            {\"id\": \"1\", \"method\": \"prompt.submit\", \"params\": {\"session_id\": \"sid\", \"text\": \"hello\"}}\n+        )\n+        assert session[\"pending_title\"] is None\n+    finally:\n+        server._sessions.pop(\"sid\", None)\n \n \n def test_config_set_yolo_toggles_session_scope():"]},{"file":"tui_gateway/server.py","hunks":["@@ -304,12 +304,14 @@ def _finalize_session(session: dict | None, end_reason: str = \"tui_close\") -> No\n     _notify_session_boundary(\"on_session_finalize\", session_id)\n \n     # Mark session ended in DB so it doesn't linger as a ghost row in /resume.\n-    # Adapted from #18283 (luyao618) and #18299 (Bartok9).\n-    if session_key:\n+    # Use session_id (from agent.session_id) not session_key — after compression,\n+    # session_key may be stale (the ended parent) while session_id is the live\n+    # continuation. Fix for #20001.\n+    if session_id:\n         try:\n             db = _get_db()\n             if db is not None:\n-                db.end_session(session_key, end_reason)\n+                db.end_session(session_id, end_reason)\n         except Exception:\n             pass\n ","@@ -1175,7 +1177,13 @@ def _compress_session_history(\n     return len(history) - len(compressed), usage\n \n \n-def _sync_session_key_after_compress(sid: str, session: dict) -> None:\n+def _sync_session_key_after_compress(\n+    sid: str,\n+    session: dict,\n+    *,\n+    clear_pending_title: bool = True,\n+    restart_slash_worker: bool = True,\n+) -> None:\n     \"\"\"Re-anchor session_key when AIAgent._compress_context rotates session_id.\n \n     AIAgent._compress_context ends the current SessionDB session and creates","@@ -1184,7 +1192,14 @@ def _sync_session_key_after_compress(sid: str, session: dict) -> None:\n     approval routing, slash worker init, DB title/history lookups, yolo\n     state).  Without this sync, those operations would target the ended\n     parent session while the agent writes to the new continuation session.\n-    Mirrors HermesCLI._manual_compress's session_id sync.\n+\n+    Policy flags:\n+        clear_pending_title: True for manual /compress (title belongs to old\n+            session). False for post-turn auto-compression (preserve user\n+            intent so pending_title can be applied to the continuation).\n+        restart_slash_worker: True for manual /compress and post-turn\n+            auto-compression (worker holds stale session key). False only\n+            if the caller manages the worker lifecycle separately.\n     \"\"\"\n     agent = session.get(\"agent\")\n     new_session_id = getattr(agent, \"session_id\", None) or \"\"","@@ -1229,11 +1244,13 @@ def _sync_session_key_after_compress(sid: str, session: dict) -> None:\n         # don't keep targeting the ended row.\n         session[\"session_key\"] = new_session_id\n \n-    session[\"pending_title\"] = None\n-    try:\n-        _restart_slash_worker(session)\n-    except Exception:\n-        pass\n+    if clear_pending_title:\n+        session[\"pending_title\"] = None\n+    if restart_slash_worker:\n+        try:\n+            _restart_slash_worker(session)\n+        except Exception:\n+            pass\n \n \n def _get_usage(agent) -> dict:","@@ -2965,6 +2982,17 @@ def _stream(delta):\n                                 \"History changed during this turn — the response above is visible \"\n                                 \"but was not saved to session history.\"\n                             )\n+\n+                # If auto-compression fired inside run_conversation(), agent.session_id\n+                # may have rotated. Sync session_key before downstream title/goal/finalize\n+                # handling uses it. Preserve pending_title (user intent) so it can be\n+                # applied to the continuation. Restart slash worker so subsequent\n+                # worker-backed commands (/title etc.) target the live session.\n+                # Fix for #20001.\n+                _sync_session_key_after_compress(\n+                    sid, session, clear_pending_title=False, restart_slash_worker=True,\n+                )\n+\n                 raw = result.get(\"final_response\", \"\")\n                 status = (\n                     \"interrupted\"","@@ -3042,11 +3070,21 @@ def _stream(delta):\n             if _pending and status == \"complete\":\n                 _pdb = _get_db()\n                 if _pdb:\n+                    _session_key = session.get(\"session_key\") or sid\n                     try:\n-                        if _pdb.set_session_title(session.get(\"session_key\") or sid, _pending):\n+                        if _pdb.set_session_title(_session_key, _pending):\n                             session[\"pending_title\"] = None\n+                    except ValueError as exc:\n+                        # Invalid/duplicate title — non-retryable, drop it.\n+                        # Auto-title will take over. Fix for #19029.\n+                        session[\"pending_title\"] = None\n+                        logger.info(\n+                            \"Dropping pending title for session %s: %s\",\n+                            _session_key, exc,\n+                        )\n                     except Exception:\n-                        pass  # Best effort — auto-title will handle it below\n+                        # Transient DB failure — keep pending_title for retry.\n+                        pass\n \n             if (\n                 status == \"complete\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}