diff --git a/agent/transcript_repair.py b/agent/transcript_repair.py index a1bed300f3..f245088abc 100644 --- a/agent/transcript_repair.py +++ b/agent/transcript_repair.py @@ -77,11 +77,13 @@ def _active_assistant_row(conn: sqlite3.Connection, session_id: str, row_id: int def sync_flushed_message_markers(batch_msgs: List[Dict[str, Any]], batch_rows: List[Dict[str, Any]]) -> None: - """Stamp _DB_PERSISTED_MARKER and sync canonical row ID / content onto live dicts after commit.""" + """Stamp _DB_PERSISTED_MARKER and sync canonical durable fields onto live dicts after commit.""" for written, row in zip(batch_msgs, batch_rows): written[_DB_PERSISTED_MARKER] = True if isinstance(row.get("_row_id"), int): written["_row_id"] = row["_row_id"] + if isinstance(row.get("timestamp"), (int, float)): + written["timestamp"] = row["timestamp"] if "_canonical_content" in row: written["content"] = row["_canonical_content"] diff --git a/hermes_state_messages.py b/hermes_state_messages.py index ebd4d13b58..be3c7c5e90 100644 --- a/hermes_state_messages.py +++ b/hermes_state_messages.py @@ -515,6 +515,11 @@ class SessionMessagesMixin: message_timestamp = _coerce_timestamp(msg.get("timestamp"), now_ts) cur = conn.execute(_INSERT_MESSAGE_SQL, self._message_row_params( session_id, role, msg, tool_calls, message_timestamp, keep_reasoning=role == "assistant")) + # Keep the caller's live row aligned with the durable identity. Rows created without an explicit + # timestamp (notably mid-turn steers) may be carried through several compaction generations; if + # the generated timestamp exists only in SQLite, every copy receives a new identity and renders + # as another logical message. + msg["timestamp"] = message_timestamp if cur.lastrowid is not None: msg["_row_id"] = cur.lastrowid inserted += 1 diff --git a/tests/agent/test_steer_compaction_persistence.py b/tests/agent/test_steer_compaction_persistence.py new file mode 100644 index 0000000000..c710fbc3d9 --- /dev/null +++ b/tests/agent/test_steer_compaction_persistence.py @@ -0,0 +1,40 @@ +"""Regression coverage for steer display identity across compactions (#117137).""" + +from unittest.mock import patch + +from agent.prompt_builder import steer_user_row +from hermes_state import SessionDB +from run_agent import AIAgent + + +def test_flushed_steer_keeps_one_display_identity_across_compactions(tmp_path): + db = SessionDB(tmp_path / "state.db") + session_id = "steer-compaction" + with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}): + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + model="test/model", + quiet_mode=True, + session_db=db, + session_id=session_id, + skip_context_files=True, + skip_memory=True, + ) + agent._ensure_db_session() + steer = steer_user_row("focus on the persistence failure") + + agent._flush_messages_to_session_db([steer], []) + + durable_timestamp = db.get_messages(session_id)[0]["timestamp"] + assert steer["timestamp"] == durable_timestamp + + for _ in range(2): + db.archive_and_compact(session_id, [dict(steer)]) + + physical = db.get_messages(session_id, include_inactive=True) + assert len([row for row in physical if row["display_kind"] == "steer"]) == 3 + visible = db.get_messages(session_id, include_compacted=True) + assert [(row["display_kind"], row["content"]) for row in visible] == [ + ("steer", steer["content"]), + ] \ No newline at end of file