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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user