Files
hermes-agent/tests/tui_gateway/test_seeded_session_create.py
Siddharth Balyan d5aaaa4a1b fix(tui-gateway): a hidden seed row stays out of search, a partial seed copy is rolled back, live resume counts the wire (#107562)
Two independent reviews of the seeded-create change found three more
places where the newly durable hidden row, or the new create-time copy,
was not handled by the same rule as the rest of the path:

- Message search (dashboard search and the session_search tool) had no
  display_kind filter, so a hidden opening row matched a query the
  person never saw. The shared search predicate now skips hidden rows.
- _seed_row left the fresh session row behind when the transcript copy
  failed after the row was committed. The first prompt's retry copies
  the whole seed, so a kept partial copy would be duplicated. The row
  is now deleted when the copy did not complete, the compensation
  _persist_branch applies to branch children; the first prompt then
  starts clean.
- _live_session_payload (a resume that reuses a live session) reported
  message_count as the raw history length while its messages array was
  filtered. It now follows _resume_response: the stored size when
  messages are omitted, else the wire count.

Tests: the two seeded-create tests now drive the first-submit path
through _persist_session_row_for_submit, the function prompt.submit
calls, and assert search and the reuse-live count; a third test pins
the rollback (no row after a failed copy, one copy after the retry).
2026-09-10 18:05:33 +00:00

118 lines
6.3 KiB
Python

"""session.create with seeded messages: the seed is durable before the first prompt, and durable once."""
from hermes_state import SessionDB
from tui_gateway import server
def _quiet_create(monkeypatch, db):
monkeypatch.setattr(server, "_get_db", lambda: db)
monkeypatch.setattr(server, "_schedule_agent_build", lambda _sid: None)
monkeypatch.setattr(server, "_schedule_session_cap_enforcement", lambda: None)
monkeypatch.setattr(server, "_register_session_cwd", lambda _session: None)
def _create(params: dict) -> dict:
resp = server.handle_request({"id": "create", "method": "session.create", "params": params})
assert "result" in resp, resp
return resp["result"]
def test_parentless_seed_survives_a_restart_and_hides_its_runbook(monkeypatch, tmp_path):
"""A client that opens a chat with its first turns already written (no parent) gets a durable row and
transcript at create: a restart before the first prompt resumes it, the hidden runbook stays out of the
transcript on the wire, and the seed is not written a second time by the first-submit path."""
db = SessionDB(db_path=tmp_path / "state.db")
_quiet_create(monkeypatch, db)
sids = []
try:
result = _create({
"cols": 96, "source": "desktop", "title": "Welcome to Hermes",
"messages": [
{"role": "user", "content": "Private setup runbook", "display_kind": "hidden"},
{"role": "assistant", "content": "Welcome to Hermes"},
# Only "hidden" is accepted from the wire; other kinds are stamped by the gateway itself.
{"role": "user", "content": "Second question", "display_kind": "steer"},
]})
sids.append(result["session_id"])
key = result["stored_session_id"]
assert [m["role"] for m in result["messages"]] == ["assistant", "user"]
assert result["message_count"] == 2 # counts what is on the wire, as session.resume does
live = server.handle_request({"id": "live", "method": "session.resume", "params": {"session_id": key, "cols": 96}})["result"]
assert (live["message_count"], len(live["messages"])) == (2, 2) # the reuse-live path counts the wire too
assert db.get_session(key)["title"] == "Welcome to Hermes"
rows = db.get_messages_as_conversation(key)
assert [r["content"] for r in rows] == ["Private setup runbook", "Welcome to Hermes", "Second question"]
assert rows[0]["display_kind"] == "hidden"
assert rows[2].get("display_kind") is None
listed = server.handle_request({"id": "list", "method": "session.list", "params": {}})["result"]["sessions"]
assert next(s for s in listed if s["id"] == key)["preview"].startswith("Second question") # the hidden row is not the preview
server._sessions.pop(sids.pop()) # the gateway restarts; only state.db remains
resumed = server.handle_request({"id": "resume", "method": "session.resume", "params": {"session_id": key, "cols": 96}})
assert "result" in resumed, resumed
sids.append(resumed["result"]["session_id"])
assert [m["role"] for m in resumed["result"]["messages"]] == ["assistant", "user"]
assert server._persist_session_row_for_submit("rid", server._sessions[sids[-1]]) is None # the first prompt.submit
assert len(db.get_messages_as_conversation(key)) == 3
assert [hit["session_id"] for hit in db.search_messages("Second question")] == [key]
assert db.search_messages("Private setup runbook") == [] # the hidden row is not searchable either
finally:
for sid in sids:
server._sessions.pop(sid, None)
db.close()
def test_branch_child_seed_is_written_once(monkeypatch, tmp_path):
"""A seeded branch child persists its copied transcript at create (#93959); the first prompt's seed
persist is the fallback for a failed create-time copy, not a second copy."""
db = SessionDB(db_path=tmp_path / "state.db")
_quiet_create(monkeypatch, db)
seed = [{"role": "user", "content": "hello from parent"}, {"role": "assistant", "content": "parent reply"}]
db.create_session("parent-1", source="desktop")
db.append_messages_batch("parent-1", seed)
db.set_session_title("parent-1", "Parent chat")
sid = None
try:
result = _create({"cols": 96, "source": "desktop", "parent_session_id": "parent-1", "messages": seed})
sid, key = result["session_id"], result["stored_session_id"]
assert [r["content"] for r in db.get_messages_as_conversation(key)] == ["hello from parent", "parent reply"]
assert server._persist_session_row_for_submit("rid", server._sessions[sid]) is None # the first prompt.submit
assert [r["content"] for r in db.get_messages_as_conversation(key)] == ["hello from parent", "parent reply"]
finally:
if sid:
server._sessions.pop(sid, None)
db.close()
def test_partial_seed_copy_is_rolled_back_not_duplicated(monkeypatch, tmp_path):
"""A seed copy that fails after the row exists leaves no row behind: the first prompt's retry copies the
whole seed again, so a kept partial copy would double it."""
db = SessionDB(db_path=tmp_path / "state.db")
_quiet_create(monkeypatch, db)
real_append, calls = db.append_messages_batch, []
def flaky_append(*args, **kwargs):
calls.append(1)
if len(calls) == 1:
raise RuntimeError("copy failed after the row was committed")
return real_append(*args, **kwargs)
monkeypatch.setattr(db, "append_messages_batch", flaky_append)
sid = None
try:
result = _create({"cols": 96, "source": "desktop", "title": "Welcome",
"messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]})
sid, key = result["session_id"], result["stored_session_id"]
assert db.get_session(key) is None # rolled back, so the first prompt starts clean
assert server._persist_session_row_for_submit("rid", server._sessions[sid]) is None
assert [r["content"] for r in db.get_messages_as_conversation(key)] == ["hi", "hello"]
assert server._sessions[sid]["pending_title"] == "Welcome" # still queued: the turn applies it, as for any lazy row
finally:
if sid:
server._sessions.pop(sid, None)
db.close()