From bbb6cc7e994ef7bae27b11fb7705cd16361f7ea5 Mon Sep 17 00:00:00 2001 From: zuowen7 <146945186+zuowen7@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:45:40 +0800 Subject: [PATCH] test: cover tool-message dedupe and latest paging for include_compacted (#80680) Dedupe key now includes tool_call_id/tool_calls/tool_name: compaction copies carry those fields verbatim, so identical tool messages across generations still collapse, while distinct tool calls sharing role/content/timestamp are never merged. Add endpoint-level coverage for the desktop's real read path (limit + order=latest + include_compacted=true). --- hermes_state.py | 13 +++++- tests/hermes_cli/test_web_server.py | 39 ++++++++++++++++ .../test_get_messages_include_compacted.py | 44 +++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/hermes_state.py b/hermes_state.py index 845120e324..342aae5394 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -9219,7 +9219,18 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) all_rows = cursor.fetchall() seen: dict = {} for row in all_rows: - key = (row["role"], row["content"], row["timestamp"]) + # Tool fields participate in the dedupe key: compaction copies + # them verbatim, so identical tool messages across generations + # still collapse, while distinct tool calls that happen to + # share role/content/timestamp are never merged. + key = ( + row["role"], + row["content"], + row["timestamp"], + row["tool_call_id"], + row["tool_calls"], + row["tool_name"], + ) cur = seen.get(key) if cur is None or (row["active"], row["id"]) > (cur["active"], cur["id"]): seen[key] = row diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index e9c5d2214d..f61ba16aa4 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1945,6 +1945,45 @@ class TestWebServerEndpoints: contents = [m["content"] for m in resp.json()["messages"]] assert contents == ["old q", "old a", "summary", "live q", "live a"] + def test_get_session_messages_latest_page_with_compacted_rows(self): + """The desktop's real read path (getLatestSessionMessages: limit + + order=latest + include_compacted=true) pages back from the newest + message and returns the window in chronological order. + """ + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="compacted-latest", source="cli") + db.append_messages_batch( + "compacted-latest", + [ + {"role": "user", "content": "old q"}, + {"role": "assistant", "content": "old a"}, + ], + ) + db.archive_and_compact( + "compacted-latest", + [ + {"role": "assistant", "content": "summary"}, + {"role": "user", "content": "live q"}, + {"role": "assistant", "content": "live a"}, + ], + ) + finally: + db.close() + + # Display history: old q, old a, summary, live q, live a (5 rows). + resp = self.client.get( + "/api/sessions/compacted-latest/messages" + "?include_compacted=true&limit=2&offset=1&order=latest" + ) + assert resp.status_code == 200 + contents = [m["content"] for m in resp.json()["messages"]] + # Newest-first window of 2, skipping the newest (live a): + # summary, live q — chronological order, matching the non-compacted path. + assert contents == ["summary", "live q"] + def test_get_session_messages_omitted_limit_defaults_to_500(self): """The dashboard must never load an entire unbounded transcript.""" from hermes_state import SessionDB diff --git a/tests/hermes_state/test_get_messages_include_compacted.py b/tests/hermes_state/test_get_messages_include_compacted.py index fabdd27d2b..4b09c7a3ae 100644 --- a/tests/hermes_state/test_get_messages_include_compacted.py +++ b/tests/hermes_state/test_get_messages_include_compacted.py @@ -205,3 +205,47 @@ class TestDisplayDedupe: page = db.get_messages(sid, include_compacted=True, limit=2, offset=2) assert [m["id"] for m in page] == all_ids[2:] assert len(page) == 2 + + def test_distinct_tool_calls_with_same_content_are_not_merged(self, db): + """Two real tool messages that happen to share role/content/timestamp + must stay separate: the dedupe key includes the tool fields, so only + genuine compaction copies (which copy those fields verbatim) collapse. + """ + sid = "s1" + db.create_session(sid, source="cli") + + def _seed_tool_rows(conn): + ts = 1700000000.0 + for cid in ("call-1", "call-2"): + conn.execute( + "INSERT INTO messages (session_id, role, content, tool_call_id," + " tool_name, timestamp, active, compacted)" + " VALUES (?, ?, ?, ?, ?, ?, 1, 0)", + (sid, "tool", "identical result", cid, "search", ts), + ) + + db._execute_write(_seed_tool_rows) + msgs = db.get_messages(sid, include_compacted=True) + assert len(msgs) == 2 + assert {m["tool_call_id"] for m in msgs} == {"call-1", "call-2"} + + def test_compaction_copies_of_tool_messages_still_collapse(self, db): + """Tool rows copied by a compaction epoch (identical tool fields) are + deduped like any other message, not split by the widened key.""" + sid = "s1" + db.create_session(sid, source="cli") + + def _seed_tool_row(conn): + conn.execute( + "INSERT INTO messages (session_id, role, content, tool_call_id," + " tool_name, timestamp, active, compacted)" + " VALUES (?, ?, ?, ?, ?, ?, 1, 0)", + (sid, "tool", "result", "call-1", "search", 1700000000.0), + ) + + db._execute_write(_seed_tool_row) + orig = _row_ids(db, sid) + self._copy_tail_as_new_generation(db, sid, orig) + msgs = db.get_messages(sid, include_compacted=True) + assert len(msgs) == 1 + assert msgs[0]["tool_call_id"] == "call-1"