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:
parent
ec3d958425
commit
4579f26308
4 changed files with 63 additions and 5 deletions
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue