fix(state): JSON-encode multimodal message content for sqlite

sqlite3 can only bind str/bytes/int/float/None to query parameters.
Multimodal message content is a list of parts (text + image_url), which
raised 'Error binding parameter 3: type list is not supported' in
append_message and replace_messages.

In the CLI/TUI this surfaced as a visible crash when users pasted
screenshots. In the gateway it was silently swallowed by a bare except
in append_to_transcript, causing multimodal turns to be lost from the
session transcript.

Fix at the DB layer: _encode_content wraps lists/dicts as
'\\x00json:' + json.dumps(...) on write, _decode_content unwraps on
read. Plain strings are untouched, so existing FTS search, previews,
and JSONL compat are unaffected. Paired decode in get_messages,
get_messages_as_conversation, and search_messages context previews.

Regression test covers: list content round-trip, dict content
round-trip, string content stored unchanged, replace_messages with
multimodal content.

Also included: aligned fix #17522 for TUI image attachment with
paths containing spaces (see previous commit).
This commit is contained in:
Teknium 2026-04-30 20:22:40 -07:00
parent cc340c4a4d
commit 531ac20408
3 changed files with 162 additions and 12 deletions

View file

@ -1243,7 +1243,7 @@ class TestRewriteTranscriptPreservesReasoning:
assert after[0].get("reasoning_details") == [{"type": "summary", "text": "step by step"}]
assert after[0].get("codex_reasoning_items") == [{"id": "r1", "type": "reasoning"}]
def test_db_rewrite_is_atomic_on_insert_failure(self, tmp_path):
def test_db_rewrite_is_atomic_on_insert_failure(self, tmp_path, monkeypatch):
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "test.db")
@ -1258,16 +1258,27 @@ class TestRewriteTranscriptPreservesReasoning:
store._db = db
store._loaded = True
# Force the second insert inside replace_messages to fail, simulating
# any storage-layer error that might abort a multi-row rewrite.
real_encode = SessionDB._encode_content
calls = {"n": 0}
def flaky_encode(cls, content):
calls["n"] += 1
if calls["n"] == 2:
raise RuntimeError("simulated storage failure")
return real_encode.__func__(cls, content)
monkeypatch.setattr(SessionDB, "_encode_content", classmethod(flaky_encode))
replacement = [
{"role": "user", "content": "after user"},
{
"role": "assistant",
"content": {"not": "sqlite-bindable but JSONL-safe"},
},
{"role": "assistant", "content": "after assistant"},
]
store.rewrite_transcript(session_id, replacement)
# The rewrite must roll back atomically — original messages preserved.
after = db.get_messages_as_conversation(session_id)
assert [msg["content"] for msg in after] == [
"before user",