From 3399bf28a5ad268ad806378346f8be0e90cf3fdd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Jul 2026 19:52:41 -0500 Subject: [PATCH] fix(state): decode display_metadata at every message read path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_messages(), get_messages_around() and get_anchored_view() returned the raw display_metadata column instead of the dict every caller expects. The desktop paints a resumed transcript from the REST prefetch, which reads through get_messages(), so any session holding an async_delegation_complete event failed resume with "Cannot use 'in' operator to search for 'task_count'" — on every such session, not just corrupted ones. Route all four read paths through one shared codec that also unwraps rows carrying a second JSON layer, so sessions already broken on disk recover on read rather than needing a migration. Co-authored-by: Studio729 Co-authored-by: aml1973 Co-authored-by: xxxigm --- hermes_state.py | 36 ++++++++++++++++--- tests/test_hermes_state.py | 74 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 6ae4863b184..90f2c8ee5d0 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -5983,6 +5983,29 @@ class SessionDB: return content return content + @staticmethod + def _decode_display_metadata(raw: Any) -> Optional[Dict[str, Any]]: + """Decode a ``display_metadata`` column into the dict every reader expects. + + Every message read path must go through this. Returning the raw TEXT + instead reaches the desktop as a string, where ``'task_count' in meta`` + throws and fails the whole resume. Some rows are double-encoded, so + unwrap a second layer when we find one. + """ + if raw is None: + return None + try: + meta = json.loads(raw) if isinstance(raw, str) else raw + if isinstance(meta, str): + meta = json.loads(meta) + except (json.JSONDecodeError, TypeError): + logger.warning("Ignoring invalid display metadata on message row") + return None + if not isinstance(meta, dict): + logger.warning("Ignoring non-object display metadata on message row") + return None + return meta + def append_message( self, session_id: str, @@ -6476,6 +6499,8 @@ class SessionDB: except (json.JSONDecodeError, TypeError): logger.warning("Failed to deserialize tool_calls in get_messages, falling back to []") msg["tool_calls"] = [] + if msg.get("display_metadata") is not None: + msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"]) result.append(msg) return result @@ -6545,6 +6570,8 @@ class SessionDB: "Failed to deserialize tool_calls in get_messages_around, falling back to []" ) msg["tool_calls"] = [] + if msg.get("display_metadata") is not None: + msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"]) result.append(msg) # before_rows includes the anchor itself; subtract 1 for the count of @@ -6667,6 +6694,8 @@ class SessionDB: "Failed to deserialize tool_calls in get_anchored_view, falling back to []" ) msg["tool_calls"] = [] + if msg.get("display_metadata") is not None: + msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"]) return msg return { @@ -6865,10 +6894,9 @@ class SessionDB: if row["display_kind"]: msg["display_kind"] = row["display_kind"] if row["display_metadata"]: - try: - msg["display_metadata"] = json.loads(row["display_metadata"]) - except (TypeError, json.JSONDecodeError): - logger.warning("Ignoring invalid display metadata on message row") + decoded = self._decode_display_metadata(row["display_metadata"]) + if decoded is not None: + msg["display_metadata"] = decoded if row["timestamp"]: msg["timestamp"] = row["timestamp"] if row["tool_call_id"]: diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index a0299df447b..f28ef42b7ba 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -7424,3 +7424,77 @@ class TestDisplayMetadataPersistence: assert len(switched) == 1 assert switched[0]["display_metadata"] == meta + +class TestDisplayMetadataReadPaths: + """Every message read path must hand back the decoded dict. + + Returning the raw column instead reaches the desktop as a string, where + ``'task_count' in meta`` throws and fails the whole session resume. + """ + + META = { + "delegation_id": "deleg_0d84d484", + "task_count": 1, + "completed_count": 1, + "failed_count": 0, + "duration_seconds": 193.55, + } + + @staticmethod + def _seed(db): + db.create_session("s1", source="desktop") + message_id = db.append_message( + "s1", "user", "event", + display_kind="async_delegation_complete", + display_metadata=TestDisplayMetadataReadPaths.META, + ) + return message_id, db.append_message("s1", "assistant", "anchor") + + @staticmethod + def _read(db, reader, message_id, anchor_id): + if reader == "get_messages": + return db.get_messages("s1")[0] + if reader == "get_messages_around": + return db.get_messages_around("s1", message_id, window=0)["window"][0] + if reader == "get_anchored_view": + view = db.get_anchored_view("s1", anchor_id, window=0, bookend=1) + return view["bookend_start"][0] + return db.get_messages_as_conversation("s1")[0] + + READERS = ("get_messages", "get_messages_around", "get_anchored_view", "conversation") + + @pytest.mark.parametrize("reader", READERS) + def test_every_reader_decodes_display_metadata(self, db, reader): + message_id, anchor_id = self._seed(db) + assert self._read(db, reader, message_id, anchor_id)["display_metadata"] == self.META + + @pytest.mark.parametrize("reader", READERS) + def test_every_reader_unwraps_double_encoded_rows(self, db, reader): + message_id, anchor_id = self._seed(db) + + def _corrupt(conn): + conn.execute( + "UPDATE messages SET display_metadata = ? WHERE id = ?", + (json.dumps(json.dumps(self.META)), message_id), + ) + + db._execute_write(_corrupt) + assert self._read(db, reader, message_id, anchor_id)["display_metadata"] == self.META + + @pytest.mark.parametrize("reader", READERS) + @pytest.mark.parametrize("raw", ["", "{not-json", "[]", '"text"', "0"]) + def test_every_reader_drops_unusable_display_metadata(self, db, reader, raw): + """Bad presentation metadata must not take the message down with it.""" + message_id, anchor_id = self._seed(db) + + def _corrupt(conn): + conn.execute( + "UPDATE messages SET display_metadata = ? WHERE id = ?", + (raw, message_id), + ) + + db._execute_write(_corrupt) + message = self._read(db, reader, message_id, anchor_id) + assert message.get("display_metadata") is None + assert message["content"] == "event" +