From fe2e6b76c4847146dbb2565c546f744f828996c5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:46:07 -0700 Subject: [PATCH] fix(tui-gateway): messaging a never-used bot no longer fails with 'session not found' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session.create intentionally persists no state.db row until the first prompt, but session.resume only looked in the database — so resuming a live lazy session by its stored key or pending title hard-404'd. Bot Mode hits this on every fresh non-default bot: the canonical Bot Chat is created lazily on the profile, the open/send resumes it, and the user gets 'session not found' on their first message to that bot. session.resume now falls back to the in-memory session registry, matching by stored key or pending title scoped to the SAME profile home. Cross-profile lookups still fail closed; unknown ids still 404. --- .../test_resume_live_lazy_session.py | 78 +++++++++++++++++++ tui_gateway/methods_session.py | 48 ++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/tui_gateway/test_resume_live_lazy_session.py diff --git a/tests/tui_gateway/test_resume_live_lazy_session.py b/tests/tui_gateway/test_resume_live_lazy_session.py new file mode 100644 index 0000000000..1bb2149039 --- /dev/null +++ b/tests/tui_gateway/test_resume_live_lazy_session.py @@ -0,0 +1,78 @@ +"""Tests: session.resume finds LIVE lazy (never-persisted) sessions. + +session.create intentionally writes no state.db row until the first prompt. +Bot Mode creates every fresh non-default bot's canonical Bot Chat exactly +that way (profile-scoped, lazy, hidden), then the open/send path resumes it +by stored key or pending title — which hard-404'd "session not found" for +every bot that had never spoken (community + Teknium repro, Aug 2026). + +Contract: +- resume by stored session_key reattaches to the live in-memory record; +- resume by pending title reattaches likewise; +- the match is scoped to the SAME profile home — an unscoped resume of a + profile-scoped live session still fails closed (no cross-profile leaks); +- a genuinely unknown id still returns 4007. +""" + +from __future__ import annotations + +import pytest + +import tui_gateway.server as srv + + +@pytest.fixture +def home(tmp_path, monkeypatch): + h = tmp_path / ".hermes" + (h / "profiles" / "ops").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(h)) + return h + + +@pytest.fixture +def live_lazy_session(home): + """A live registry record shaped like session.create's lazy output.""" + sid = "live-lazy-1" + record = { + "history": [], + "last_active": 0.0, + "pending_title": "Bot Chat", + "pending_hidden": True, + "profile_home": str(home / "profiles" / "ops"), + "running": False, + "session_key": "20260823_000000_abc123", + "source": "desktop", + } + srv._sessions[sid] = record + yield sid, record + srv._sessions.pop(sid, None) + + +def _resume(params): + return srv._methods["session.resume"](1, params) + + +def test_resume_by_stored_key_reattaches(live_lazy_session): + sid, record = live_lazy_session + out = _resume({"profile": "ops", "session_id": record["session_key"], "omit_messages": True}) + assert "error" not in out, out + assert out["result"]["session_id"] == sid + assert out["result"]["stored_session_id"] == record["session_key"] + + +def test_resume_by_pending_title_reattaches(live_lazy_session): + sid, _record = live_lazy_session + out = _resume({"profile": "ops", "session_id": "Bot Chat", "omit_messages": True}) + assert "error" not in out, out + assert out["result"]["session_id"] == sid + + +def test_unscoped_resume_of_profile_session_fails_closed(live_lazy_session): + _sid, record = live_lazy_session + out = _resume({"session_id": record["session_key"], "omit_messages": True}) + assert out.get("error", {}).get("code") == 4007 + + +def test_unknown_id_still_404s(home): + out = _resume({"profile": "ops", "session_id": "ghost-9999", "omit_messages": True}) + assert out.get("error", {}).get("code") == 4007 diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 97ebfbb02e..d9bf97d6f9 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -407,6 +407,54 @@ def _(rid, params: dict) -> dict: # streams the whole turn anyway and the row exists by upgrade time. found = {} else: + # LIVE lazy session: session.create intentionally persists no + # state.db row until the first prompt (no "Untitled" litter), + # so a resume by the stored key or pending title lands here for + # every never-messaged session. Bot Mode hits it on every fresh + # non-default bot — the canonical Bot Chat is created lazily on + # the profile, the open/send then resumes it, and this hard 404 + # ("session not found") killed messaging for exactly the bots + # that had never spoken. Match the in-memory registry by stored + # key or pending title, scoped to the SAME profile home this + # resume targets, and hand the caller the live record. + # (Nested per method_ctx rebinding — module helpers are + # invisible from installed handlers.) + def _find_live_unpersisted(needle: str, home) -> str: + want_home = str(home) if home is not None else None + for live_sid, record in list(_sessions.items()): + if not isinstance(record, dict): + continue + if (record.get("profile_home") or None) != want_home: + continue + if ( + str(record.get("session_key") or "") == needle + or (record.get("pending_title") or "") == needle + ): + return live_sid + return "" + + live_sid = _find_live_unpersisted(target, profile_home) + live = _sessions.get(live_sid) if live_sid else None + if live is not None: + if owns_db: + with contextlib.suppress(Exception): + db.close() + live["last_active"] = time.time() + history = live.get("history") or [] + return _ok( + rid, + { + "session_id": live_sid, + "stored_session_id": str(live.get("session_key") or ""), + "message_count": len(history), + "messages": [] if omit_messages else _history_to_messages(history), + "info": { + "model": _resolve_model(), + "lazy": True, + "profile_name": profile or "", + }, + }, + ) return _err(rid, 4007, "session not found") # Follow the compression-continuation chain to the live tip so a resume on