02e270a47e
* fix(gateway): persist prompt.submit truncation to the session's own profile DB `_get_db()` returns the LAUNCH profile's SessionDB handle. App-global remote mode gives a session its own profile (`session["profile_home"]`) whose transcript lives in that profile's `state.db`, so a write keyed on `session_key` that goes through `_get_db()` addresses the wrong database. In the `prompt.submit` truncate branch that has two consequences. The edit/resend never sticks — `session.resume` reopens the profile db and resurrects the undone turns — and when the launch profile happens to hold a row under the same session id, the truncated transcript is inserted into a profile the session does not belong to. It also silently voids the branch's own fail-closed contract. The handler persists before it rewrites `session["history"]` precisely so that a failed write refuses the turn and leaves memory and DB aligned; that only holds if the handle it checks is the one that owns the row. `_session_db(session)` is the profile-aware resolver that already exists for this: the profile's `state.db` when `profile_home` is set, otherwise the shared launch handle. Non-profile sessions are unaffected — `_session_db` borrows the same shared handle and leaves it open. `active_only=True` and `archive_dropped=True` are carried through unchanged; only the handle the call is made against changes. * fix(gateway): resolve the /undo command against the session's own profile DB `command.dispatch`'s `/undo` branch opened the launch profile's handle via `_get_db()`, but every read and write under it is scoped by session id: `list_recent_user_messages`, `rewind_to_message` and the `get_messages_as_conversation` reload all key on `session_key`. For a session with its own profile (`session["profile_home"]`) the rows live in that profile's `state.db`, so against the launch handle `list_recent_user_messages` returns nothing and the command fails closed with `4018 "no user messages to undo"` — for the entire session, on every invocation, even though the transcript is right there in the profile db. Route the whole branch through `_session_db(session)`, which yields the db that owns the session's row and closes a profile handle on exit. Sessions without a profile keep borrowing the shared launch handle exactly as before, so this is behaviourally identical for them. * fix(gateway): read /history and /context from the session's own profile DB `_format_live_history_output` and `_format_live_context_output` rebuild the transcript from the database rather than from `session["history"]`, because the in-memory list is empty for a session this process did not run itself. Both reads are scoped by session id but were issued against `_get_db()`, the launch profile's handle. A session with its own profile (`session["profile_home"]`) keeps its rows in that profile's `state.db`, so both reads come back empty and the commands under-report: `/history` renders "No conversation history yet." and `/context` falls back to the empty in-memory list and reports a conversation of zero messages. Both swallow their exceptions, so there is no error either — just a wrong answer about the user's own transcript. Resolve both through `_session_db(session)`, the profile-aware resolver used by the rest of the session-scoped paths. * test(gateway): cover session-scoped transcript ops against a profile DB Regression coverage for the three session-scoped sites that resolved against the launch profile's handle instead of the db owning the session's row. Each test drives the real JSON-RPC entry point with a session carrying `profile_home`, seeds the transcript into the profile's own `state.db`, and asserts against both databases. Per site, with the production change reverted to its pre-fix form: - `prompt.submit` truncation — `test_truncation_persists_to_the_profile_db` and `test_truncation_does_not_copy_rows_into_the_launch_profile` fail. The second seeds a row under the same session id in the launch db so the foreign write succeeds instead of failing a key check, which is the case that copies a transcript into a profile it does not belong to. - `/undo` — `test_undo_rewinds_the_profile_transcript` fails with `4018 "no user messages to undo"`. - `/history` and `/context` — `test_history_reads_the_profile_transcript` and `test_context_reads_the_profile_transcript` fail, reporting an empty conversation. `test_undo_still_uses_the_shared_handle_without_a_profile` and `test_truncation_without_a_profile_uses_the_shared_handle` pin the unchanged path: with no `profile_home` the resolver must borrow the shared launch handle and leave it open. Both stay green in every direction, so a future change cannot satisfy the profile cases by abandoning the shared one. * fix(desktop): aim truncations by durable id alone on tail-only transcripts The cold-open transcript is a newest-first prefetch page (LATEST_SESSION_MESSAGES_LIMIT = 120) with the resume RPC sent omit_messages — older rows only arrive via "Show earlier" backfill. planEdit/planReload/planRestore still counted truncate ordinals over that windowed list, so every edit/reload/restore in a session longer than the prefetch page sent a window-relative ordinal alongside the durable row/message id. The gateway's #82959 cross-check resolved the durable id to its full-history ordinal, read the offset as drift, and refused with 4030 — making the Edit affordance permanently dead in long sessions. When the transcript may be tail-only (the transcript-tail bookkeeping's possiblyTruncated), drop the client ordinal and address the truncation by durable id alone — the same rule runRewindSubmit already applies to content-resolved row ids (#87059). The ordinal tripwire stays on whenever the transcript is complete. Closes #88082 * fix(desktop): drop client rewind ordinal whenever a durable id is present #88092 gated the drop on tail-only prefetch. After in-place compact the live scrollback is treated as complete, so Restore still sent a display-lineage ordinal next to a resolved row id and the gateway refused with 4030 (#89244). prefix_user_count is structurally 0 on in-place because get_ancestor_display_prefix is cross-session. Same choke point: if a durable truncate_before_row_id or a real truncate_before_message_id is present, omit the client ordinal. confirm_empty_truncate is still carried from a caller ordinal of 0. Unknown ids still fail closed at 4018. Closes #89244 --------- Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com> Co-authored-by: zengzheqing <yuntianqing@yahoo.com>
393 lines
14 KiB
Python
393 lines
14 KiB
Python
"""Session-scoped transcript ops must resolve against the session's own DB.
|
|
|
|
App-global remote mode gives a session its own profile (``profile_home`` on
|
|
the session dict, see ``session.create``), and that profile keeps its own
|
|
``state.db``. ``_get_db()`` is the *launch* profile's handle, so any
|
|
session-scoped read or write that reaches for it operates on the wrong
|
|
database: writes land in a foreign profile under this session's id, and reads
|
|
come back empty because the row simply is not there.
|
|
|
|
``_session_db(session)`` is the profile-aware resolver that already exists for
|
|
exactly this (``tui_gateway/server.py``): the profile's ``state.db`` when
|
|
``session['profile_home']`` is set, otherwise the shared launch handle.
|
|
|
|
Every test here drives the real JSON-RPC entry point
|
|
(``server.handle_request``). Handler bodies live in ``tui_gateway/methods_*``
|
|
but are rebound onto ``server.py``'s globals by
|
|
``method_ctx.HandlerRegistry.install()``, so calling a handler function
|
|
directly would bypass the path the gateway actually executes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import threading
|
|
import types
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
SESSION_ID = "sid-profile"
|
|
SESSION_KEY = "tui-profile-1"
|
|
|
|
|
|
@pytest.fixture()
|
|
def hermes_home(tmp_path, monkeypatch):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
yield home
|
|
|
|
|
|
@pytest.fixture()
|
|
def server(hermes_home):
|
|
# Mocks are scoped to the initial import only (see
|
|
# tests/tui_gateway/test_protocol.py for the rationale).
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"hermes_cli.env_loader": MagicMock(),
|
|
"hermes_cli.banner": MagicMock(),
|
|
},
|
|
):
|
|
mod = importlib.import_module("tui_gateway.server")
|
|
|
|
methods = dict(mod._methods)
|
|
yield mod
|
|
# Restore in place instead of clear+reload: importlib.reload re-registers
|
|
# atexit hooks and re-captures module-level paths against this test's
|
|
# soon-deleted tmpdir (see tests/tui_gateway/test_undo_command.py).
|
|
mod._methods.clear()
|
|
mod._methods.update(methods)
|
|
mod._sessions.clear()
|
|
mod._pending.clear()
|
|
mod._answers.clear()
|
|
mod._db = None
|
|
|
|
|
|
@pytest.fixture()
|
|
def launch_db(server, hermes_home):
|
|
"""The launch profile's state.db, wired in as the ``_get_db()`` handle."""
|
|
db = SessionDB(db_path=hermes_home / "state.db")
|
|
server._db = db
|
|
return db
|
|
|
|
|
|
@pytest.fixture()
|
|
def profile_db(tmp_path):
|
|
"""A second, non-launch profile's state.db."""
|
|
profile_home = tmp_path / "profiles" / "work"
|
|
profile_home.mkdir(parents=True)
|
|
return profile_home, SessionDB(db_path=profile_home / "state.db")
|
|
|
|
|
|
def _seed(db, turns=3, *, _capture_user_row_ids=None):
|
|
db.create_session(SESSION_KEY, source="tui")
|
|
for i in range(1, turns + 1):
|
|
uid = db.append_message(SESSION_KEY, "user", f"question {i}")
|
|
if _capture_user_row_ids is not None:
|
|
_capture_user_row_ids.append(uid)
|
|
db.append_message(SESSION_KEY, "assistant", f"answer {i}")
|
|
return db.get_messages_as_conversation(SESSION_KEY)
|
|
|
|
|
|
def _register(server, history, *, profile_home=None):
|
|
# SimpleNamespace, not MagicMock: the usage snapshot compares attributes
|
|
# numerically, and auto-created mock attributes are not orderable.
|
|
agent = types.SimpleNamespace(
|
|
_memory_manager=MagicMock(),
|
|
_last_flushed_db_idx=len(history),
|
|
model="test-model",
|
|
)
|
|
session = {
|
|
"session_key": SESSION_KEY,
|
|
"history": list(history),
|
|
"history_lock": threading.Lock(),
|
|
"history_version": 0,
|
|
"running": False,
|
|
"agent": agent,
|
|
"attached_images": [],
|
|
"image_counter": 0,
|
|
"cols": 120,
|
|
# The cap slot is claimed on the first real turn; pre-claim it so the
|
|
# test exercises the transcript path rather than the lease allocator.
|
|
"active_session_lease": object(),
|
|
}
|
|
if profile_home is not None:
|
|
session["profile_home"] = str(profile_home)
|
|
server._sessions[SESSION_ID] = session
|
|
return session
|
|
|
|
|
|
def _rpc(server, method, params):
|
|
return server.handle_request({"id": "1", "method": method, "params": params})
|
|
|
|
|
|
def _texts(rows):
|
|
out = []
|
|
for row in rows:
|
|
content = row.get("content")
|
|
if isinstance(content, list):
|
|
content = "".join(
|
|
part.get("text", "")
|
|
for part in content
|
|
if isinstance(part, dict) and part.get("type") == "text"
|
|
)
|
|
out.append(str(content or ""))
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /undo — command.dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_undo_rewinds_the_profile_transcript(server, launch_db, profile_db):
|
|
"""/undo on a profile session must read and rewind that profile's db.
|
|
|
|
``list_recent_user_messages`` is session-id scoped, so against the launch
|
|
handle it finds nothing and /undo fails closed with 4018 — the command is
|
|
unusable for the entire session in app-global remote mode.
|
|
"""
|
|
profile_home, pdb = profile_db
|
|
history = _seed(pdb)
|
|
_register(server, history, profile_home=profile_home)
|
|
|
|
resp = _rpc(
|
|
server,
|
|
"command.dispatch",
|
|
{"session_id": SESSION_ID, "name": "undo", "arg": ""},
|
|
)
|
|
|
|
assert not resp.get("error"), f"/undo failed: {resp.get('error')}"
|
|
result = resp["result"]
|
|
assert result["type"] == "prefill"
|
|
assert result["message"] == "question 3"
|
|
# The rewind is durable in the profile's own db, not the launch one.
|
|
assert _texts(pdb.get_messages_as_conversation(SESSION_KEY)) == [
|
|
"question 1",
|
|
"answer 1",
|
|
"question 2",
|
|
"answer 2",
|
|
]
|
|
assert launch_db.get_messages_as_conversation(SESSION_KEY) == []
|
|
|
|
|
|
def test_undo_still_uses_the_shared_handle_without_a_profile(server, launch_db):
|
|
"""A launch-profile session keeps borrowing the shared ``_get_db()`` handle."""
|
|
history = _seed(launch_db)
|
|
_register(server, history)
|
|
|
|
resp = _rpc(
|
|
server,
|
|
"command.dispatch",
|
|
{"session_id": SESSION_ID, "name": "undo", "arg": ""},
|
|
)
|
|
|
|
assert not resp.get("error"), f"/undo failed: {resp.get('error')}"
|
|
assert resp["result"]["message"] == "question 3"
|
|
assert _texts(launch_db.get_messages_as_conversation(SESSION_KEY)) == [
|
|
"question 1",
|
|
"answer 1",
|
|
"question 2",
|
|
"answer 2",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# edit/resend truncation — prompt.submit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stop_after_truncate(server, monkeypatch):
|
|
"""Return the RPC right after the truncate branch, before the agent turn.
|
|
|
|
Isolated turns hand the prompt to the compute host and return, which is
|
|
the natural exit closest to the code under test; stubbing the handoff keeps
|
|
the test on the transcript-persistence path instead of running a model.
|
|
"""
|
|
monkeypatch.setattr(server, "_session_uses_compute_host", lambda *a, **k: True)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_submit_prompt_to_compute_host",
|
|
lambda rid, sid, session, text, **_kwargs: server._ok(
|
|
rid, {"status": "streaming"}
|
|
),
|
|
)
|
|
|
|
|
|
def test_truncation_persists_to_the_profile_db(server, launch_db, profile_db, monkeypatch):
|
|
"""An edit/resend must truncate the profile's transcript, not the launch one."""
|
|
profile_home, pdb = profile_db
|
|
user_row_ids: list = []
|
|
history = _seed(pdb, _capture_user_row_ids=user_row_ids)
|
|
_register(server, history, profile_home=profile_home)
|
|
_stop_after_truncate(server, monkeypatch)
|
|
|
|
# Row-id addressed, not ordinal-only: current main refuses a bare ordinal
|
|
# for a durable session (truncate_before_row_id required). Target the 2nd
|
|
# user turn's durable row id; keep the matching ordinal as a cross-check.
|
|
resp = _rpc(
|
|
server,
|
|
"prompt.submit",
|
|
{
|
|
"session_id": SESSION_ID,
|
|
"text": "edited question 2",
|
|
"truncate_before_row_id": user_row_ids[1],
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
)
|
|
|
|
assert not resp.get("error"), f"prompt.submit failed: {resp.get('error')}"
|
|
# The undone turns are gone from the profile's own db, so session.resume
|
|
# (which opens the profile db correctly) cannot resurrect them.
|
|
assert _texts(pdb.get_messages_as_conversation(SESSION_KEY)) == [
|
|
"question 1",
|
|
"answer 1",
|
|
]
|
|
# ...and nothing was copied into a foreign profile under this session id.
|
|
assert launch_db.get_messages_as_conversation(SESSION_KEY) == []
|
|
|
|
|
|
def test_truncation_does_not_copy_rows_into_the_launch_profile(
|
|
server, launch_db, profile_db, monkeypatch
|
|
):
|
|
"""The launch profile must not receive a copy of a profile session's turns.
|
|
|
|
When the launch db happens to hold a row under the same session id, the
|
|
write through the launch handle succeeds instead of failing the foreign-key
|
|
check, so the truncated transcript is inserted into a profile the session
|
|
does not belong to.
|
|
"""
|
|
profile_home, pdb = profile_db
|
|
user_row_ids: list = []
|
|
history = _seed(pdb, _capture_user_row_ids=user_row_ids)
|
|
launch_db.create_session(SESSION_KEY, source="unknown")
|
|
_register(server, history, profile_home=profile_home)
|
|
_stop_after_truncate(server, monkeypatch)
|
|
|
|
resp = _rpc(
|
|
server,
|
|
"prompt.submit",
|
|
{
|
|
"session_id": SESSION_ID,
|
|
"text": "edited question 2",
|
|
"truncate_before_row_id": user_row_ids[1],
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
)
|
|
|
|
assert not resp.get("error"), f"prompt.submit failed: {resp.get('error')}"
|
|
assert launch_db.get_messages_as_conversation(SESSION_KEY) == []
|
|
assert _texts(pdb.get_messages_as_conversation(SESSION_KEY)) == [
|
|
"question 1",
|
|
"answer 1",
|
|
]
|
|
|
|
|
|
def test_truncation_surfaces_the_profile_dbs_new_row_ids(
|
|
server, launch_db, profile_db, monkeypatch
|
|
):
|
|
"""``survivor_user_row_ids`` must carry the profile db's post-rewrite ids.
|
|
|
|
``replace_messages`` re-inserts the surviving prefix as NEW rows and the
|
|
client rebinds its cached stamps from this payload, so the ids have to come
|
|
from the db that actually did the rewrite. Ids minted anywhere else address
|
|
nothing in the profile's transcript, and the next rewind is refused 4018.
|
|
"""
|
|
profile_home, pdb = profile_db
|
|
user_row_ids: list = []
|
|
history = _seed(pdb, _capture_user_row_ids=user_row_ids)
|
|
_register(server, history, profile_home=profile_home)
|
|
_stop_after_truncate(server, monkeypatch)
|
|
|
|
resp = _rpc(
|
|
server,
|
|
"prompt.submit",
|
|
{
|
|
"session_id": SESSION_ID,
|
|
"text": "edited question 2",
|
|
"truncate_before_row_id": user_row_ids[1],
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
)
|
|
|
|
assert not resp.get("error"), f"prompt.submit failed: {resp.get('error')}"
|
|
surviving = [
|
|
row["_row_id"]
|
|
for row in pdb.get_messages_as_conversation(SESSION_KEY, include_row_ids=True)
|
|
if row["role"] == "user"
|
|
]
|
|
assert resp["result"]["survivor_user_row_ids"] == surviving
|
|
# Fresh rows, not the pre-rewind ids the client sent in.
|
|
assert user_row_ids[0] not in surviving
|
|
|
|
|
|
def test_truncation_without_a_profile_uses_the_shared_handle(server, launch_db, monkeypatch):
|
|
user_row_ids: list = []
|
|
history = _seed(launch_db, _capture_user_row_ids=user_row_ids)
|
|
_register(server, history)
|
|
_stop_after_truncate(server, monkeypatch)
|
|
|
|
resp = _rpc(
|
|
server,
|
|
"prompt.submit",
|
|
{
|
|
"session_id": SESSION_ID,
|
|
"text": "edited question 2",
|
|
"truncate_before_row_id": user_row_ids[1],
|
|
"truncate_before_user_ordinal": 1,
|
|
"confirm_truncate": True,
|
|
},
|
|
)
|
|
|
|
assert not resp.get("error"), f"prompt.submit failed: {resp.get('error')}"
|
|
assert _texts(launch_db.get_messages_as_conversation(SESSION_KEY)) == [
|
|
"question 1",
|
|
"answer 1",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /history and /context — slash.exec
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_history_reads_the_profile_transcript(server, launch_db, profile_db):
|
|
"""/history must render the profile session's own transcript."""
|
|
profile_home, pdb = profile_db
|
|
_seed(pdb)
|
|
# In-memory history is deliberately empty: the point of the db read is to
|
|
# rebuild the transcript for a session the process did not run itself.
|
|
_register(server, [], profile_home=profile_home)
|
|
|
|
resp = _rpc(server, "slash.exec", {"session_id": SESSION_ID, "command": "/history"})
|
|
|
|
assert not resp.get("error"), f"/history failed: {resp.get('error')}"
|
|
output = resp["result"]["output"]
|
|
assert "question 3" in output
|
|
assert "answer 3" in output
|
|
|
|
|
|
def test_context_reads_the_profile_transcript(server, launch_db, profile_db, monkeypatch):
|
|
"""/context must count the profile session's own messages."""
|
|
profile_home, pdb = profile_db
|
|
_seed(pdb)
|
|
_register(server, [], profile_home=profile_home)
|
|
# /context is an isolated-session read command, gated on the compute host.
|
|
monkeypatch.setattr(server, "_session_uses_compute_host", lambda *a, **k: True)
|
|
|
|
resp = _rpc(server, "slash.exec", {"session_id": SESSION_ID, "command": "/context"})
|
|
|
|
assert not resp.get("error"), f"/context failed: {resp.get('error')}"
|
|
output = resp["result"]["output"]
|
|
assert "Conversation: 6 messages" in output
|
|
assert "user: 3, assistant: 3" in output
|