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:
Brooklyn Nicholson 2026-07-24 19:52:41 -05:00
parent bb4765d21c
commit 3399bf28a5
2 changed files with 106 additions and 4 deletions

View file

@ -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"]:

View file

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