fix(tui-gateway): messaging a never-used bot no longer fails with 'session not found'

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.
This commit is contained in:
Teknium
2026-08-23 04:46:07 -07:00
parent a4c6c6bddb
commit fe2e6b76c4
2 changed files with 126 additions and 0 deletions
@@ -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
+48
View File
@@ -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