mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(state): decode display_metadata at every message read path
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 <Studio729@users.noreply.github.com> Co-authored-by: aml1973 <aml1973@users.noreply.github.com> Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
This commit is contained in:
parent
bb4765d21c
commit
3399bf28a5
2 changed files with 106 additions and 4 deletions
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue