Files
hermes-agent/tests/hermes_state/test_reasoning_roundtrip.py
cryptoyasenka f2d03c1f2a fix(state,cli,tui-gateway): keep reasoning fields intact across forks and branches
get_messages() only deserializes content and tool_calls; the structured
reasoning columns (reasoning_details, codex_reasoning_items,
codex_message_items) come back as the raw TEXT they were stored as.
Feeding those rows straight back into a write, which is exactly what
the POST /api/sessions/{id}/fork handler does by piping get_messages()
into replace_messages(), hit an unguarded json.dumps() and stored the
already-serialized string encoded a second time. On replay of the fork,
json.loads() then yields the inner string instead of a list, and every
consumer's isinstance(..., list) gate silently drops it: preserved
Anthropic thinking blocks, Codex encrypted-reasoning/message-item
replay, and OpenRouter multi-turn reasoning context are all lost after
a fork, with one more encoding layer added per fork.

The /branch copy loop had the same defect from the other side: it
forwarded reasoning but none of the structured columns, and both TUI
branch writers persisted role/content alone, dropping reasoning and
reasoning_content along with them.

Route the six dumps sites in append_message and _insert_message_rows
through a shared guard that keeps already-serialized strings as-is;
structured values from the live runtime are dumped exactly as before.
Forward the reasoning fields in all three branch writers, matching the
set gateway/slash_commands.py already forwards on its own /branch path.
2026-08-08 17:37:26 -07:00

119 lines
4.1 KiB
Python

"""Round-trip tests for the structured reasoning columns.
get_messages() returns reasoning_details / codex_reasoning_items /
codex_message_items as the raw TEXT stored in their columns (it only
hydrates content and tool_calls). Callers that feed those rows straight
back into a write — the POST /api/sessions/{id}/fork handler pipes
get_messages() into replace_messages() — must not re-encode that TEXT,
or the forked session replays with reasoning fields decoding to strings
and every isinstance(..., list) consumer silently drops them.
"""
import pytest
from hermes_state import SessionDB
REASONING_DETAILS = [
{"type": "reasoning.text", "text": "compare both branches first", "format": "unknown"}
]
CODEX_REASONING_ITEMS = [
{"id": "rs_1", "type": "reasoning", "encrypted_content": "opaque-blob"}
]
CODEX_MESSAGE_ITEMS = [
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "done"}],
}
]
@pytest.fixture
def db(tmp_path):
return SessionDB(tmp_path / "state.db")
def _seed(db, sid="src"):
"""Session with one assistant message carrying all three reasoning fields."""
db.create_session(sid, source="cli")
db.append_message(sid, role="user", content="hi")
db.append_message(
sid,
role="assistant",
content="done",
reasoning_details=REASONING_DETAILS,
codex_reasoning_items=CODEX_REASONING_ITEMS,
codex_message_items=CODEX_MESSAGE_ITEMS,
)
def _fork(db, src, dst):
"""The fork handler's copy step: raw get_messages rows into replace_messages."""
db.create_session(dst, source="cli")
db.replace_messages(dst, db.get_messages(src))
def _assistant(conversation):
return next(m for m in conversation if m["role"] == "assistant")
class TestDirectWrite:
"""Live-runtime path: structured values in, structured values back."""
def test_reasoning_fields_hydrate_as_structures(self, db):
_seed(db)
msg = _assistant(db.get_messages_as_conversation("src"))
assert msg["reasoning_details"] == REASONING_DETAILS
assert msg["codex_reasoning_items"] == CODEX_REASONING_ITEMS
assert msg["codex_message_items"] == CODEX_MESSAGE_ITEMS
class TestForkRoundTrip:
"""get_messages -> replace_messages must keep the stored TEXT intact."""
def test_reasoning_details_survive_fork(self, db):
_seed(db)
_fork(db, "src", "fork")
msg = _assistant(db.get_messages_as_conversation("fork"))
assert msg["reasoning_details"] == REASONING_DETAILS
def test_codex_reasoning_items_survive_fork(self, db):
_seed(db)
_fork(db, "src", "fork")
msg = _assistant(db.get_messages_as_conversation("fork"))
assert msg["codex_reasoning_items"] == CODEX_REASONING_ITEMS
def test_codex_message_items_survive_fork(self, db):
_seed(db)
_fork(db, "src", "fork")
msg = _assistant(db.get_messages_as_conversation("fork"))
assert msg["codex_message_items"] == CODEX_MESSAGE_ITEMS
def test_fork_of_fork_stays_stable(self, db):
# Each extra round-trip used to add another encoding layer.
_seed(db)
_fork(db, "src", "fork1")
_fork(db, "fork1", "fork2")
msg = _assistant(db.get_messages_as_conversation("fork2"))
assert msg["reasoning_details"] == REASONING_DETAILS
assert msg["codex_reasoning_items"] == CODEX_REASONING_ITEMS
assert msg["codex_message_items"] == CODEX_MESSAGE_ITEMS
class TestAppendMessageRoundTrip:
"""append_message accepts a stored row's already-serialized TEXT too."""
def test_string_value_not_double_encoded(self, db):
_seed(db)
row = next(m for m in db.get_messages("src") if m["role"] == "assistant")
db.create_session("copy", source="cli")
db.append_message(
"copy",
role="assistant",
content="done",
reasoning_details=row["reasoning_details"],
)
msg = _assistant(db.get_messages_as_conversation("copy"))
assert msg["reasoning_details"] == REASONING_DETAILS