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" +