From d5aaaa4a1ba7311e21b59df41c39838fef2991d4 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:35:33 +0530 Subject: [PATCH] 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). --- hermes_state_search.py | 2 + .../tui_gateway/test_seeded_session_create.py | 38 ++++++++++++++++++- tui_gateway/methods_session.py | 17 +++++++-- tui_gateway/server.py | 7 +++- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/hermes_state_search.py b/hermes_state_search.py index 4f7744fb4c..f1dadec66d 100644 --- a/hermes_state_search.py +++ b/hermes_state_search.py @@ -135,6 +135,8 @@ def _search_filter_clauses( rewind/undo rows (active=0, compacted=0) are hidden.""" if not include_inactive: where.append("(m.active = 1 OR m.compacted = 1)") + # display_kind="hidden" rows are model-facing scaffolding the person never saw; a hit would confuse. + where.append("COALESCE(m.display_kind, '') <> 'hidden'") if source_filter is not None: where.append(f"s.source IN ({','.join('?' for _ in source_filter)})") params.extend(source_filter) diff --git a/tests/tui_gateway/test_seeded_session_create.py b/tests/tui_gateway/test_seeded_session_create.py index cdfac7541a..66f27ec272 100644 --- a/tests/tui_gateway/test_seeded_session_create.py +++ b/tests/tui_gateway/test_seeded_session_create.py @@ -37,6 +37,8 @@ def test_parentless_seed_survives_a_restart_and_hides_its_runbook(monkeypatch, t 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) @@ -52,8 +54,10 @@ def test_parentless_seed_survives_a_restart_and_hides_its_runbook(monkeypatch, t sids.append(resumed["result"]["session_id"]) assert [m["role"] for m in resumed["result"]["messages"]] == ["assistant", "user"] - server._persist_branch_seed(server._sessions[sids[-1]]) # what the first prompt.submit calls + 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) @@ -75,9 +79,39 @@ def test_branch_child_seed_is_written_once(monkeypatch, tmp_path): 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"] - server._persist_branch_seed(server._sessions[sid]) + 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() diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 41bee69e5d..c9d8d6eaa7 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -281,18 +281,27 @@ def _seed_row(record: dict) -> None: """Persist a parentless seeded session NOW, for the reason ``_seed_branch_row`` gives: seeded content is intent, not an abandoned draft, and the renderer's post-create hydration reads the DB. The client's title lands with the row so a restart before the first prompt keeps it. Best-effort — the first-prompt path is - the fallback.""" + the fallback, and it re-copies the WHOLE seed, so a partial copy is rolled back here (the compensation + ``_persist_branch`` applies to branch children) rather than left to be duplicated.""" + key = record.get("session_key") try: if _ensure_session_db_row(record) is False: return _persist_branch_seed(record) + except Exception: + logger.warning("seeded-session persistence failed for %s; falling back to lazy row creation", key, exc_info=True) + if not record.get("_branch_seed_persisted"): + with contextlib.suppress(Exception), _session_db(record) as db: + if db is not None: + db.delete_session(key) + return + try: if title := record.get("pending_title"): with _session_db(record) as db: - if db is not None and db.set_session_title(record["session_key"], title): + if db is not None and db.set_session_title(key, title): record["pending_title"] = None except Exception: - logger.warning("seeded-session persistence failed for %s; falling back to lazy row creation", - record.get("session_key"), exc_info=True) + logger.debug("seeded-session title write failed for %s; pending_title stays queued", key, exc_info=True) def _create_overrides(params: dict) -> tuple: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5a89d346cd..7203e95ab0 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2714,9 +2714,12 @@ def _live_session_payload( else: with _session_db(session) as db: history = _live_visible_history(session, db, in_memory_history) + # message_count follows _resume_response: the stored size when messages are omitted, else the wire count + # (a hidden seed row is in ``history`` but never on the wire). + messages = [] if omit_messages else _history_to_messages(history) payload = { - "info": _fallback_session_info(session), "message_count": len(history), - "messages": [] if omit_messages else _history_to_messages(history), + "info": _fallback_session_info(session), "message_count": len(history) if omit_messages else len(messages), + "messages": messages, "messages_omitted": omit_messages, "running": running, "turn_started_at": turn_started_at, "session_id": sid, "session_key": _session_lookup_key(session, fallback=sid), "started_at": float(session.get("created_at") or time.time()),