From e7b915592c8d68dede0e87082d4a32b2bc51a643 Mon Sep 17 00:00:00 2001 From: nankingjing <1079826437@qq.com> Date: Fri, 3 Jul 2026 13:11:15 +0800 Subject: [PATCH] fix(state): avoid double-encoding reasoning columns on fork round-trip Fixes #57240 get_messages() leaves reasoning_details/codex_* columns as stored TEXT. replace_messages() must not json.dumps() those strings again or forked sessions silently lose reasoning replay on resume. Add _encode_json_column() and use it in append_message and _insert_message_rows so pre-serialized TEXT passes through unchanged. --- hermes_state.py | 41 +++++++++++++++++++------------------- tests/test_hermes_state.py | 28 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index ac1a126675322..d94d7ca5b3992 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3092,6 +3092,20 @@ def _decode_content(cls, content: Any) -> Any: return content return content + @classmethod + def _encode_json_column(cls, value: Any) -> Optional[str]: + """Serialize a JSON column value for storage. + + Accepts either a live structure (list/dict) or the column's own + already-serialized TEXT from ``get_messages()`` — the latter must + not be double-encoded on ``replace_messages()`` round-trips (#57240). + """ + if not value: + return None + if isinstance(value, str): + return value + return json.dumps(value) + def append_message( self, session_id: str, @@ -3124,18 +3138,9 @@ def append_message( message by its platform-side identifier. """ # Serialize structured fields to JSON before entering the write txn - reasoning_details_json = ( - json.dumps(reasoning_details) - if reasoning_details else None - ) - codex_items_json = ( - json.dumps(codex_reasoning_items) - if codex_reasoning_items else None - ) - codex_message_items_json = ( - json.dumps(codex_message_items) - if codex_message_items else None - ) + reasoning_details_json = self._encode_json_column(reasoning_details) + codex_items_json = self._encode_json_column(codex_reasoning_items) + codex_message_items_json = self._encode_json_column(codex_message_items) tool_calls_json = json.dumps(tool_calls) if tool_calls else None # Multimodal content (list of parts) must be JSON-encoded: sqlite3 # cannot bind list/dict parameters directly. @@ -3232,15 +3237,9 @@ def _insert_message_rows(self, conn, session_id: str, messages: List[Dict[str, A codex_message_items = ( msg.get("codex_message_items") if role == "assistant" else None ) - reasoning_details_json = ( - json.dumps(reasoning_details) if reasoning_details else None - ) - codex_items_json = ( - json.dumps(codex_reasoning_items) if codex_reasoning_items else None - ) - codex_message_items_json = ( - json.dumps(codex_message_items) if codex_message_items else None - ) + reasoning_details_json = self._encode_json_column(reasoning_details) + codex_items_json = self._encode_json_column(codex_reasoning_items) + codex_message_items_json = self._encode_json_column(codex_message_items) tool_calls_json = json.dumps(tool_calls) if tool_calls else None # Accept either `platform_message_id` (new explicit name) or # `message_id` (yuanbao's existing convention on message dicts). diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index eb884129bc56f..d90668d4faa82 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1,5 +1,6 @@ """Tests for hermes_state.py — SessionDB SQLite CRUD, FTS5 search, export.""" +import json import sqlite3 import time import pytest @@ -847,6 +848,33 @@ def test_replace_messages_handles_multimodal_content(self, db): assert msgs[0]["content"] == content assert msgs[1]["content"] == "I see a screenshot." + def test_get_messages_roundtrip_preserves_reasoning_columns(self, db): + """get_messages → replace_messages must not double-encode reasoning JSON (#57240).""" + reasoning = [{"type": "thinking", "thinking": "step one"}] + codex_items = [{"type": "reasoning", "encrypted_content": "enc_abc"}] + db.create_session(session_id="parent", source="api_server") + db.append_message( + "parent", + role="assistant", + content="answer", + reasoning_details=reasoning, + codex_reasoning_items=codex_items, + ) + + # get_messages leaves reasoning columns as stored TEXT — that is the + # fork/branch copy path (POST /api/sessions/{id}/fork). + loaded = db.get_messages("parent") + assert json.loads(loaded[0]["reasoning_details"]) == reasoning + assert json.loads(loaded[0]["codex_reasoning_items"]) == codex_items + + fork_id = "parent-fork" + db.create_session(session_id=fork_id, source="api_server", parent_session_id="parent") + db.replace_messages(fork_id, db.get_messages("parent")) + + fork_conv = db.get_messages_as_conversation(fork_id) + assert fork_conv[0]["reasoning_details"] == reasoning + assert fork_conv[0]["codex_reasoning_items"] == codex_items + def test_get_messages_as_conversation(self, db): db.create_session(session_id="s1", source="cli") db.append_message("s1", role="user", content="Hello")