From 4579f263088fa90aa5cb58d7cbf903d2e32ccb8f Mon Sep 17 00:00:00 2001 From: Frowtek Date: Thu, 16 Jul 2026 16:03:40 +0300 Subject: [PATCH] fix(state): heal alternation at the ACP / CLI-resume / TUI-resume restore sites too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- acp_adapter/session.py | 10 ++++- hermes_cli/cli_commands_mixin.py | 10 ++++- .../test_restore_alternation_repair.py | 42 +++++++++++++++++++ tui_gateway/server.py | 6 ++- 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/acp_adapter/session.py b/acp_adapter/session.py index a51c4c58a..6f1e17a07 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -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 = [] diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index c8bf1e671..e1c1dfa78 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -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 diff --git a/tests/hermes_state/test_restore_alternation_repair.py b/tests/hermes_state/test_restore_alternation_repair.py index 90824c81a..74df7bcbb 100644 --- a/tests/hermes_state/test_restore_alternation_repair.py +++ b/tests/hermes_state/test_restore_alternation_repair.py @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7f1a7ffe3..f83b4486b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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()