Files
hermes-agent/tests/tui_gateway/test_composite_carrier_rewind.py
teknium1 0dcd558c78 fix(tui_gateway): rewind test expects the edited prompt to be durable at submit
test_prompt_row_id_rewind_preserves_scaffold_before_regeneration asserted that a
truncate_before_row_id rewind leaves exactly one active row (the hidden compaction
scaffold). That reflected the pre-#111868 timing, where the user turn only reached
the DB through the agent's own flush — which the test's stub agent never runs.

The previous commit persists the submitted prompt at send time, and on the rewind
path that write happens after the truncated transcript is written, so the durable
transcript is now "scaffold + EDITED ASK" (the archived carrier and its reply stay
inactive). This is the same contract the sibling rewind tests in
test_tui_gateway_server.py were already updated to; the source needs no change.
The test now checks the scaffold survives the cut AND that the edited prompt is the
single active user turn after it, with a row id newer than the cut target.
2026-09-17 09:05:51 -07:00

519 lines
17 KiB
Python

"""Regression contracts for TUI rewind of live compaction carriers."""
from __future__ import annotations
import threading
from types import SimpleNamespace
import pytest
from agent.context_compressor import (
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
_SUMMARY_END_MARKER,
)
from hermes_state import SessionDB
from tui_gateway import server
def _composite_carrier() -> dict:
return {
"role": "user",
"content": (
f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold task\n\n"
f"{_SUMMARY_END_MARKER}\n\nREAL ASK"
),
}
@pytest.fixture()
def carrier_session(tmp_path):
old_db = server._db
db = SessionDB(db_path=tmp_path / "state.db")
installed_ids: list[str] = []
def install(history: list[dict]):
sid = f"carrier-sid-{len(installed_ids)}"
session_key = f"carrier-session-{len(installed_ids)}"
installed_ids.append(sid)
db.create_session(session_key, source="tui")
for message in history:
db.append_message(
session_key,
message["role"],
message.get("content"),
)
durable = db.get_messages_as_conversation(session_key)
agent = SimpleNamespace(
_session_messages=list(durable),
_last_flushed_db_idx=len(durable),
_db_flush_scan_prefix=list(durable),
)
session = {
"agent": agent,
"attached_images": [],
"history": list(durable),
"history_lock": threading.Lock(),
"history_version": 0,
"running": False,
"session_key": session_key,
}
server._sessions[sid] = session
return sid, session_key, session
server._db = db
yield db, install
for sid in installed_ids:
server._sessions.pop(sid, None)
server._db = old_db
db.close()
def _dispatch(sid: str, name: str) -> dict:
return server._methods["command.dispatch"](
"request-id",
{"session_id": sid, "name": name, "arg": ""},
)
def _session_undo(sid: str) -> dict:
return server._methods["session.undo"](
"request-id",
{"session_id": sid},
)
def _assert_scaffold_preserved(
db: SessionDB,
session_key: str,
session: dict,
*,
prefix_len: int = 0,
) -> None:
active = db.get_messages_as_conversation(session_key, include_row_ids=True)
assert len(active) == prefix_len + 1
scaffold = active[prefix_len]
assert scaffold["role"] == "user"
assert scaffold["display_kind"] == "hidden"
assert SUMMARY_PREFIX in scaffold["content"]
assert "REAL ASK" not in scaffold["content"]
assert session["history"][prefix_len]["content"] == scaffold["content"]
assert session["history"][prefix_len]["display_kind"] == "hidden"
def test_retry_selects_the_live_ask_inside_a_force_user_leading_carrier(
carrier_session,
):
db, install = carrier_session
sid, session_key, session = install(
[_composite_carrier(), {"role": "assistant", "content": "failed"}]
)
response = _dispatch(sid, "retry")
assert response["result"] == {"type": "send", "message": "REAL ASK"}
_assert_scaffold_preserved(db, session_key, session)
@pytest.mark.parametrize("command", ["retry", "undo"])
def test_rewind_matches_cold_sanitized_carrier_to_unchanged_warm_ask(
carrier_session, command
):
db, install = carrier_session
carrier = _composite_carrier()
carrier["content"] = carrier["content"].replace(
"REAL ASK",
" REAL ASK\n\n<memory-context>\nprivate\n</memory-context> ",
)
sid, session_key, session = install(
[carrier, {"role": "assistant", "content": "failed"}]
)
db._conn.execute(
"UPDATE messages SET api_content = ? "
"WHERE session_id = ? AND role = 'user'",
(carrier["content"], session_key),
)
db._conn.commit()
# A live agent still has the raw wire form while a cold DB projection has
# already applied the role-aware sanitize_context(...).strip() rule and
# retained the raw provider wire in api_content.
session["history"][0] = carrier.copy()
session["agent"]._session_messages = list(session["history"])
response = _dispatch(sid, command)
assert response["result"]["message"] == "REAL ASK"
_assert_scaffold_preserved(db, session_key, session)
def test_retry_fails_closed_when_transcript_changes_after_snapshot(
carrier_session, monkeypatch
):
db, install = carrier_session
sid, session_key, session = install(
[_composite_carrier(), {"role": "assistant", "content": "failed"}]
)
sibling = SessionDB(db_path=db.db_path)
original_rewind = db.rewind_to_message
def _append_then_rewind(*args, **kwargs):
sibling.append_message(session_key, "assistant", "concurrent tail")
return original_rewind(*args, **kwargs)
monkeypatch.setattr(db, "rewind_to_message", _append_then_rewind)
before_history = [dict(message) for message in session["history"]]
response = _dispatch(sid, "retry")
assert response["error"]["code"] == 5008
assert "active transcript changed" in response["error"]["message"]
assert session["history"] == before_history
rows = db._conn.execute(
"SELECT content, active, display_kind FROM messages "
"WHERE session_id = ? ORDER BY id",
(session_key,),
).fetchall()
assert [tuple(row) for row in rows] == [
(_composite_carrier()["content"], 1, None),
("failed", 1, None),
("concurrent tail", 1, None),
]
sibling.close()
@pytest.mark.parametrize("command", ["retry", "undo"])
def test_rewind_allows_database_only_reaction_metadata_change(
carrier_session, command
):
db, install = carrier_session
sid, session_key, session = install(
[
{"role": "user", "content": "OLDER ASK"},
{"role": "assistant", "content": "older answer"},
_composite_carrier(),
{"role": "assistant", "content": "failed"},
]
)
older_answer = next(
row
for row in db.get_messages(session_key)
if row["role"] == "assistant" and row["content"] == "older answer"
)
assert db.set_message_reaction(
session_key, older_answer["id"], "👍", author="user"
)
response = _dispatch(sid, command)
assert response["result"]["message"] == "REAL ASK"
_assert_scaffold_preserved(db, session_key, session, prefix_len=2)
def test_retry_ignores_buried_ephemeral_scaffolding_missing_from_db(
carrier_session,
):
db, install = carrier_session
sid, session_key, session = install(
[
{"role": "user", "content": "OLDER ASK"},
{"role": "assistant", "content": "older answer"},
_composite_carrier(),
{"role": "assistant", "content": "failed"},
]
)
session["history"].insert(
2,
{
"role": "user",
"content": "internal recovery nudge",
"_dropped_toolcall_nudge": True,
},
)
session["agent"]._session_messages = list(session["history"])
response = _dispatch(sid, "retry")
assert response["result"] == {"type": "send", "message": "REAL ASK"}
_assert_scaffold_preserved(db, session_key, session, prefix_len=2)
def test_retry_drops_buried_ephemeral_scaffolding_from_the_warm_prefix(
carrier_session,
):
db, install = carrier_session
sid, session_key, session = install(
[
{"role": "user", "content": "OLDER ASK"},
{"role": "assistant", "content": "candidate answer"},
{"role": "assistant", "content": "verified answer"},
_composite_carrier(),
{"role": "assistant", "content": "failed"},
]
)
session["history"].insert(
2,
{
"role": "user",
"content": "[System: verify before stopping]",
"_verification_stop_synthetic": True,
},
)
session["agent"]._session_messages = list(session["history"])
response = _dispatch(sid, "retry")
assert response["result"] == {"type": "send", "message": "REAL ASK"}
assert [message.get("content") for message in session["history"][:3]] == [
"OLDER ASK",
"candidate answer",
"verified answer",
]
active = db.get_messages_as_conversation(session_key, include_row_ids=True)
# Alternation repair is a model/memory projection; the two durable source
# rows remain independently recoverable ahead of the inserted scaffold.
assert [message.get("content") for message in active[:3]] == [
"OLDER ASK",
"candidate answer",
"verified answer",
]
assert active[3]["display_kind"] == "hidden"
assert "REAL ASK" not in active[3]["content"]
assert session["history"][3]["display_kind"] == "hidden"
assert "REAL ASK" not in session["history"][3]["content"]
def test_retry_preserves_older_warm_media_while_targeting_plain_ask(
carrier_session,
):
db, install = carrier_session
sid, session_key, session = install(
[
{"role": "user", "content": "look\n[screenshot]"},
{"role": "assistant", "content": "seen"},
_composite_carrier(),
{"role": "assistant", "content": "failed"},
]
)
session["history"][0]["content"] = [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}},
]
session["agent"]._session_messages = list(session["history"])
response = _dispatch(sid, "retry")
assert response["result"] == {"type": "send", "message": "REAL ASK"}
assert isinstance(session["history"][0]["content"], list)
_assert_scaffold_preserved(db, session_key, session, prefix_len=2)
def test_undo_targets_the_composite_carrier_not_an_older_user_turn(
carrier_session,
):
db, install = carrier_session
sid, session_key, session = install(
[
{"role": "user", "content": "OLDER ASK"},
{"role": "assistant", "content": "older answer"},
_composite_carrier(),
{"role": "assistant", "content": "failed"},
]
)
response = _dispatch(sid, "undo")
assert response["result"]["type"] == "prefill"
assert response["result"]["message"] == "REAL ASK"
active = db.get_messages_as_conversation(session_key, include_row_ids=True)
assert [message.get("content") for message in active[:2]] == [
"OLDER ASK",
"older answer",
]
_assert_scaffold_preserved(db, session_key, session, prefix_len=2)
def test_undo_rewinds_media_placeholder_without_treating_it_as_retry(
carrier_session,
):
db, install = carrier_session
carrier = _composite_carrier()
carrier["content"] = carrier["content"].replace(
"REAL ASK", "look\n[screenshot]"
)
sid, session_key, session = install(
[carrier, {"role": "assistant", "content": "seen"}]
)
response = _dispatch(sid, "undo")
assert response["result"]["type"] == "prefill"
assert response["result"]["message"] == "look\n[screenshot]"
active = db.get_messages_as_conversation(session_key)
assert len(active) == 1
assert active[0]["display_kind"] == "hidden"
assert "look\n[screenshot]" not in active[0]["content"]
assert len(session["history"]) == 1
assert session["history"][0]["content"] == active[0]["content"]
assert session["history"][0]["display_kind"] == "hidden"
def test_session_undo_preserves_the_composite_carriers_scaffold(carrier_session):
db, install = carrier_session
sid, session_key, session = install(
[_composite_carrier(), {"role": "assistant", "content": "answer"}]
)
response = _session_undo(sid)
assert response["result"]["removed"] == 2
_assert_scaffold_preserved(db, session_key, session)
def test_history_projection_unwraps_composite_and_hides_sole_handoff():
composite = {**_composite_carrier(), "_row_id": 7}
sole_handoff = {
**composite,
"content": composite["content"].split("\n\nREAL ASK", 1)[0],
}
assert server._history_to_messages(
[composite, {"role": "user", "content": "newer ask", "_row_id": 9}]
) == [
{"role": "user", "text": "REAL ASK", "row_id": 7},
{"role": "user", "text": "newer ask", "row_id": 9},
]
assert server._history_to_messages([sole_handoff]) == []
def test_retry_preserves_literal_media_like_text(carrier_session):
db, install = carrier_session
carrier = _composite_carrier()
carrier["content"] = carrier["content"].replace(
"REAL ASK", "inspect [image|ybres:RID]"
)
sid, session_key, session = install(
[carrier, {"role": "assistant", "content": "failed"}]
)
response = _dispatch(sid, "retry")
assert response["result"] == {
"type": "send",
"message": "inspect [image|ybres:RID]",
}
_assert_scaffold_preserved(db, session_key, session)
def test_retry_rejects_durable_media_before_rewind_when_warm_view_is_text(
carrier_session,
):
db, install = carrier_session
carrier = _composite_carrier()
handoff = carrier["content"].rsplit("\n\nREAL ASK", 1)[0]
durable_carrier = carrier.copy()
durable_carrier["content"] = [
{"type": "text", "text": handoff},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}},
]
sid, session_key, session = install(
[durable_carrier, {"role": "assistant", "content": "failed"}]
)
# The warm projection can be a degraded text-only view that compares equal
# to the durable media payload. Durable retryability must still be checked
# before the physical carrier and tail are archived.
warm_carrier = carrier.copy()
warm_carrier["content"] = handoff + "\n\n[screenshot]"
session["history"][0] = warm_carrier
session["agent"]._session_messages = list(session["history"])
before_history = [message.copy() for message in session["history"]]
response = _dispatch(sid, "retry")
assert response["error"]["code"] == 4018
assert session["history"] == before_history
assert len(db.get_messages_as_conversation(session_key)) == 2
def test_retry_rejects_pending_attachments_before_mutating_history(carrier_session):
db, install = carrier_session
sid, session_key, session = install(
[_composite_carrier(), {"role": "assistant", "content": "failed"}]
)
session["attached_images"] = ["/tmp/pending.png"]
before_memory = list(session["history"])
response = _dispatch(sid, "retry")
assert response["error"]["code"] == 4018
assert session["history"] == before_memory
assert len(db.get_messages_as_conversation(session_key)) == 2
def test_prompt_row_id_rewind_preserves_scaffold_before_regeneration(
carrier_session, monkeypatch
):
db, install = carrier_session
sid, session_key, session = install(
[_composite_carrier(), {"role": "assistant", "content": "failed"}]
)
target_row_id = db.get_messages_as_conversation(
session_key, include_row_ids=True
)[0]["_row_id"]
seen = {}
class _Agent:
_session_messages = list(session["history"])
_last_flushed_db_idx = len(_session_messages)
_db_flush_scan_prefix = list(_session_messages)
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None, **_kwargs
):
seen["prompt"] = prompt
seen["history"] = list(conversation_history or [])
return {
"final_response": "regenerated",
"messages": [
*(conversation_history or []),
{"role": "user", "content": prompt},
{"role": "assistant", "content": "regenerated"},
],
}
class _ImmediateThread:
def __init__(self, target=None, daemon=None, args=(), kwargs=None, name=None):
self._target = lambda: target(*args, **(kwargs or {}))
def start(self):
self._target()
session["agent"] = _Agent()
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
monkeypatch.setattr(server, "_get_usage", lambda _agent: {})
monkeypatch.setattr(server, "render_message", lambda *_args: "")
monkeypatch.setattr(server, "_emit", lambda *_args: None)
response = server._methods["prompt.submit"](
"request-id",
{
"session_id": sid,
"text": "EDITED ASK",
"truncate_before_row_id": target_row_id,
"truncate_before_user_ordinal": 0,
"confirm_truncate": True,
},
)
assert response["result"]["status"] == "streaming"
assert seen["prompt"] == "EDITED ASK"
assert len(seen["history"]) == 1
assert seen["history"][0]["display_kind"] == "hidden"
assert "REAL ASK" not in seen["history"][0]["content"]
active = db.get_messages_as_conversation(session_key, include_row_ids=True)
# The cut keeps only the scaffold; the edited prompt is then durable at submit (#111868),
# written AFTER the truncation, so it is the single active user turn after the scaffold.
assert [(m.get("display_kind"), m["content"]) for m in active] == [
("hidden", active[0]["content"]),
(None, "EDITED ASK"),
]
assert "REAL ASK" not in active[0]["content"]
assert active[1]["_row_id"] > target_row_id