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:
@@ -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",
|
||||
|
||||
@@ -212,6 +212,82 @@ class TestMessageStorage:
|
||||
messages = db.get_messages("s1")
|
||||
assert messages[0]["tool_calls"] == tool_calls
|
||||
|
||||
def test_multimodal_list_content_round_trip(self, db):
|
||||
"""Multimodal ``content`` (list of parts) must survive the SQLite
|
||||
round-trip. sqlite3 cannot bind Python lists directly, so the DB
|
||||
layer JSON-encodes structured content on write and decodes on read.
|
||||
|
||||
Regression test for the "Error binding parameter 3: type 'list' is
|
||||
not supported" crash users hit when pasting screenshots into the
|
||||
TUI (issue #17522).
|
||||
"""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
content = [
|
||||
{"type": "text", "text": "describe this screenshot"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,iVBORw0KG..."},
|
||||
},
|
||||
]
|
||||
|
||||
# Write must not raise
|
||||
db.append_message("s1", role="user", content=content)
|
||||
|
||||
# get_messages decodes back to the original list
|
||||
msgs = db.get_messages("s1")
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["content"] == content
|
||||
|
||||
# get_messages_as_conversation decodes back to the original list
|
||||
conv = db.get_messages_as_conversation("s1")
|
||||
assert len(conv) == 1
|
||||
assert conv[0] == {"role": "user", "content": content}
|
||||
|
||||
def test_dict_content_round_trip(self, db):
|
||||
"""Dict-shaped content (e.g. provider wrappers) also round-trips."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
content = {"parts": [{"text": "hi"}]}
|
||||
|
||||
db.append_message("s1", role="user", content=content)
|
||||
msgs = db.get_messages("s1")
|
||||
assert msgs[0]["content"] == content
|
||||
|
||||
def test_string_content_unchanged_by_encoding(self, db):
|
||||
"""Plain strings must not be wrapped — FTS search and legacy
|
||||
consumers depend on raw-string storage for text content.
|
||||
"""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="user", content="plain text")
|
||||
|
||||
# Peek at the raw column to confirm no encoding was applied
|
||||
with db._lock:
|
||||
row = db._conn.execute(
|
||||
"SELECT content FROM messages WHERE session_id = ?", ("s1",)
|
||||
).fetchone()
|
||||
assert row["content"] == "plain text"
|
||||
|
||||
def test_replace_messages_handles_multimodal_content(self, db):
|
||||
"""`replace_messages` (used by /retry, /undo, /compress) must also
|
||||
handle list content without crashing."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
content = [
|
||||
{"type": "text", "text": "look at this"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}},
|
||||
]
|
||||
|
||||
db.replace_messages(
|
||||
"s1",
|
||||
[
|
||||
{"role": "user", "content": content},
|
||||
{"role": "assistant", "content": "I see a screenshot."},
|
||||
],
|
||||
)
|
||||
|
||||
msgs = db.get_messages("s1")
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["content"] == content
|
||||
assert msgs[1]["content"] == "I see a screenshot."
|
||||
|
||||
def test_get_messages_as_conversation(self, db):
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="user", content="Hello")
|
||||
|
||||
Reference in New Issue
Block a user