fix(state): heal alternation at the ACP / CLI-resume / TUI-resume restore sites too

Follow-up to the restore-boundary alternation heal (#65492): get_messages_
as_conversation grew a repair_alternation flag, wired into gateway
load_transcript and the CLI startup resume. Three other LIVE-REPLAY
restore sites still loaded the transcript verbatim, so a durable
'user;user' violation there re-fires the pre-request defensive repair on
every request for the rest of the session (it only ever mutates the
per-request list, never the restored working conversation):

- acp_adapter/session.py::SessionManager._restore — the loaded history
  becomes the resumed ACP (Zed) agent's SessionState.history.
- hermes_cli/cli_commands_mixin.py — the /resume slash command sets
  self.conversation_history from the load (the startup resume was fixed,
  this mid-session one was missed).
- tui_gateway/server.py — the resume handler feeds the load into the
  deferred session record's working conversation.

Pass repair_alternation=True at all three so the wedge is healed once at
restore. Inspection/export consumers (trace upload, context guard,
api_server history, display_history) keep the verbatim default.

Adds an end-to-end regression test driving the ACP _restore path: a
seeded user;user session restores to an alternation-clean live history
with no user input lost.
This commit is contained in:
Frowtek 2026-07-16 16:03:40 +03:00 committed by Teknium
parent ec3d958425
commit 4579f26308
4 changed files with 63 additions and 5 deletions

View file

@ -534,9 +534,15 @@ class SessionManager:
model = row.get("model") or None
# Load conversation history.
# Load conversation history. repair_alternation: this restore feeds
# LIVE REPLAY — the loaded list becomes the resumed agent's working
# conversation. A durable ``user;user`` violation left in state.db would
# otherwise re-fire the pre-request defensive repair on every request
# for the rest of the session (see hermes_state.get_messages_as_conversation).
try:
history = db.get_messages_as_conversation(session_id)
history = db.get_messages_as_conversation(
session_id, repair_alternation=True
)
except Exception:
logger.warning("Failed to load messages for ACP session %s", session_id, exc_info=True)
history = []

View file

@ -770,8 +770,14 @@ class CLICommandsMixin:
self._pending_title = None
_sync_process_session_id(target_id)
# Load conversation history (strip transcript-only metadata entries)
restored = self._session_db.get_messages_as_conversation(target_id)
# Load conversation history (strip transcript-only metadata entries).
# repair_alternation: this /resume feeds LIVE REPLAY — ``restored``
# becomes ``self.conversation_history`` for subsequent turns. Heal a
# durable ``user;user`` violation once here instead of re-firing the
# pre-request repair on every request for the rest of the session.
restored = self._session_db.get_messages_as_conversation(
target_id, repair_alternation=True
)
restored = [m for m in (restored or []) if m.get("role") != "session_meta"]
self.conversation_history = restored

View file

@ -70,3 +70,45 @@ def test_repair_noop_on_clean_transcript(db):
repaired = db.get_messages_as_conversation("s2", repair_alternation=True)
assert [m["role"] for m in repaired] == [m["role"] for m in verbatim]
assert [m["content"] for m in repaired] == [m["content"] for m in verbatim]
# ---------------------------------------------------------------------------
# The live-replay restore SITES must pass repair_alternation=True. The initial
# fix covered gateway load_transcript + CLI startup resume; these are the other
# live-replay restore paths (ACP session resume, CLI /resume, TUI resume) that
# hand the loaded transcript to a live agent for subsequent turns.
# ---------------------------------------------------------------------------
def _seed_wedged_acp_session(db, session_id="acp1"):
db.create_session(session_id, "acp")
db.append_message(session_id=session_id, role="user", content="first ask")
db.append_message(session_id=session_id, role="assistant", content="first reply")
db.append_message(session_id=session_id, role="user", content="unanswered turn")
db.append_message(session_id=session_id, role="user", content="next turn")
db.append_message(session_id=session_id, role="assistant", content="next reply")
def test_acp_restore_heals_alternation_for_live_replay(db):
"""acp_adapter.SessionManager._restore feeds LIVE REPLAY: the loaded history
becomes the resumed agent's working conversation. It must be alternation-
clean so the pre-request repair doesn't re-fire every turn."""
from acp_adapter.session import SessionManager
_seed_wedged_acp_session(db, "acp1")
class _StubAgent:
model = "stub"
mgr = SessionManager(agent_factory=lambda: _StubAgent(), db=db)
state = mgr._restore("acp1")
assert state is not None
roles = [m["role"] for m in state.history]
# No consecutive user turns — the durable user;user wedge was healed.
assert roles == ["user", "assistant", "user", "assistant"], roles
for a, b in zip(roles, roles[1:]):
assert not (a == "user" and b == "user"), "unhealed user;user in ACP live replay"
# No user input lost — both user texts survive, merged in order.
merged = state.history[2]["content"]
assert "unanswered turn" in merged and "next turn" in merged

View file

@ -6000,7 +6000,11 @@ def _(rid, params: dict) -> dict:
db.reopen_session(target)
# The child's OWN conversation only — include_ancestors would prepend
# the parent's transcript onto the subagent's branch.
history = db.get_messages_as_conversation(target)
# repair_alternation: this resume feeds LIVE REPLAY (the loaded
# history becomes the resumed session record's working conversation),
# so heal a durable ``user;user`` violation once here instead of
# re-firing the pre-request repair on every subsequent turn.
history = db.get_messages_as_conversation(target, repair_alternation=True)
except Exception as e:
if lease is not None:
lease.release()