Files
hermes-webui/tests/test_state_db_content_sentinel_decode.py
T
totalitarianandtotalitarian 963e1bae8a Decode state.db content sentinel in the WebUI message projection (#7492)
Decode the state.db content sentinel in the WebUI message projection so structured/multimodal content renders instead of a placeholder, with an out-of-band tuple identity that no scalar can forge and a per-call ContextVar memo for the canonical serialisation.

Co-authored-by: totalitarian <totalitarian@users.noreply.github.com>
2026-09-15 17:12:45 -07:00

417 lines
17 KiB
Python

"""Regression tests for the state.db structured-content sentinel.
hermes_state stores list/dict message content as a NUL-sentinel JSON string.
While the WebUI projector left it undecoded, an uploaded image's base64 data
URI reached the transcript as literal text -- one unbreakable ~65k-character
run -- and the browser spent minutes computing its min-content width.
The decode is deliberately narrow: it must not widen ``content`` into any
shape the rest of the WebUI pipeline cannot already render.
"""
import json
from api.models import (
_content_identity_for_key,
_decode_state_db_content,
_project_state_db_message,
_session_message_dedup_key,
_session_message_merge_key,
_session_message_multimodal_mirror_key,
_session_message_visible_key,
)
PREFIX = "\x00json:"
TEXT_AND_IMAGE = [
{"type": "text", "text": "here is a screenshot"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
def _sentinel(payload_json: str) -> str:
return PREFIX + payload_json
# --- the fix itself -------------------------------------------------------
def test_supported_list_root_is_decoded():
assert _decode_state_db_content(_sentinel(json.dumps(TEXT_AND_IMAGE))) == TEXT_AND_IMAGE
def test_projector_decodes_content():
row = {"role": "user", "content": _sentinel(json.dumps(TEXT_AND_IMAGE)),
"timestamp": 1.0, "id": 1}
msg = _project_state_db_message(row, available=set(), id_col=False, optional=())
assert msg["content"] == TEXT_AND_IMAGE
# --- finding #1: dict roots must NOT be widened ---------------------------
def test_dict_root_falls_back_to_raw_string():
"""A dict root reaches _renderCacheKey(), which calls text.slice() on it
and throws, blanking the turn. It must stay a string."""
raw = _sentinel(json.dumps({"type": "text", "text": "hi"}))
assert _decode_state_db_content(raw) == raw
def test_scalar_roots_fall_back_to_raw_string():
for payload in ("42", '"just a string"', "true", "null"):
raw = _sentinel(payload)
assert _decode_state_db_content(raw) == raw
# --- finding #2: non-finite numbers break browser JSON.parse --------------
def test_non_finite_constants_fall_back_to_raw_string():
for literal in ("NaN", "Infinity", "-Infinity"):
raw = _sentinel('[{"type": "text", "text": 1}, %s]' % literal)
assert _decode_state_db_content(raw) == raw
def test_overflowed_float_falls_back_to_raw_string():
raw = _sentinel('[{"type": "text", "text": "x", "score": 1e400}]')
assert _decode_state_db_content(raw) == raw
def test_decoded_payload_is_always_browser_parseable():
decoded = _decode_state_db_content(_sentinel(json.dumps(TEXT_AND_IMAGE)))
json.loads(json.dumps(decoded, allow_nan=False))
# --- finding #3: only the schema the UI actually renders ------------------
def test_unsupported_part_shapes_fall_back_to_raw_string():
unsupported = [
[{"type": "input_text", "text": "dropped by the JS readers"}],
[{"type": "output_text", "text": "also dropped"}],
[{"type": "tool_use", "id": "t1"}],
["a bare scalar part"],
[{"text": "no type key"}],
[{"type": "text", "text": {"not": "a string"}}],
[],
]
for payload in unsupported:
raw = _sentinel(json.dumps(payload))
assert _decode_state_db_content(raw) == raw, payload
def test_image_only_list_stays_raw_because_it_would_not_render():
"""msgContent() discards image parts and this projection supplies no
attachments, so an image-only row would decode to nothing visible and
_messageIsRenderable() would hide it. It must stay a raw string instead."""
payload = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}]
raw = _sentinel(json.dumps(payload))
assert _decode_state_db_content(raw) == raw
# --- passthrough / malformed ---------------------------------------------
def test_plain_values_pass_through_unchanged():
for value in ("hello", "", None, 42, ["already", "a", "list"], "see json: below"):
assert _decode_state_db_content(value) == value
def test_malformed_sentinel_payload_returns_raw_string():
raw = _sentinel("{not valid json")
assert _decode_state_db_content(raw) == raw
# --- finding #4: identities must be type-namespaced ----------------------
def test_structured_content_cannot_collide_with_its_own_repr():
structured = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1.0}
scalar = {"role": "user", "content": str(TEXT_AND_IMAGE), "timestamp": 1.0}
assert _session_message_merge_key(structured) != _session_message_merge_key(scalar)
assert _session_message_dedup_key(structured) != _session_message_dedup_key(scalar)
def test_rich_turns_with_different_images_keep_distinct_identities():
def turn(url):
return {"role": "user", "timestamp": 1.0, "content": [
{"type": "text", "text": "same visible text"},
{"type": "image_url", "image_url": {"url": url}},
]}
a, b = turn("data:image/png;base64,AAAA"), turn("data:image/png;base64,BBBB")
assert _session_message_merge_key(a) != _session_message_merge_key(b)
assert _session_message_dedup_key(a) != _session_message_dedup_key(b)
def test_scalar_content_identity_is_unchanged():
"""Existing scalar behaviour must not shift."""
assert _content_identity_for_key("plain text") == "plain text"
assert _content_identity_for_key(None) == ""
assert _content_identity_for_key("") == ""
def test_mirror_bridge_never_pairs_rich_to_rich():
rich = {"role": "user", "timestamp": 1.0, "content": TEXT_AND_IMAGE}
assert _session_message_multimodal_mirror_key(rich, require_image_parts=True) is not None
# the scalar side of the bridge must refuse a rich row
assert _session_message_multimodal_mirror_key(rich, require_scalar_mirror=True) is None
# and the two flags are mutually exclusive by construction
assert _session_message_multimodal_mirror_key(
rich, require_image_parts=True, require_scalar_mirror=True
) is None
def test_mirror_bridge_still_accepts_a_scalar_mirror():
scalar = {"role": "user", "timestamp": 1.0, "content": "[screenshot] here is a screenshot"}
assert _session_message_multimodal_mirror_key(scalar, require_scalar_mirror=True) is not None
# --- finding #5: prefix and tail keys share one representation -----------
def test_prefix_and_tail_keys_agree_for_a_sentinel_row():
raw = _sentinel(json.dumps(TEXT_AND_IMAGE))
row = {"role": "user", "content": raw, "timestamp": 5.0, "id": 7}
tail_msg = _project_state_db_message(row, available=set(), id_col=False, optional=())
prefix_msg = {
"role": row["role"],
"content": _decode_state_db_content(row["content"]),
"tool_calls": None,
"api_content": None,
}
tail_key = _session_message_visible_key(
{"role": tail_msg.get("role"), "content": tail_msg.get("content"),
"tool_calls": None, "api_content": None},
normalize_workspace_prefix=True,
)
prefix_key = _session_message_visible_key(prefix_msg, normalize_workspace_prefix=True)
assert prefix_key == tail_key
# --- every read path that projects content must decode (behavioural) ------
#
# Keys derived on one read path are compared against keys derived on another,
# so a read path that projected the column raw while another decoded it would
# silently reintroduce the prefix/tail mismatch. These exercise each path
# against a real sentinel-encoded row rather than inspecting source.
def _make_state_db(path, sid, rows):
import sqlite3
conn = sqlite3.connect(path)
conn.execute(
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, title TEXT, "
"model TEXT, started_at REAL, message_count INTEGER)"
)
conn.execute(
"CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, "
"role TEXT, content TEXT, timestamp REAL, tool_call_id TEXT, tool_calls TEXT, "
"tool_name TEXT, active INTEGER DEFAULT 1, api_content TEXT)"
)
conn.execute(
"INSERT INTO sessions (id, source, title, model, started_at, message_count) "
"VALUES (?, ?, ?, ?, ?, ?)",
(sid, "webui", "Sentinel", "test-model", 1000.0, len(rows)),
)
for row in rows:
conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp, active) "
"VALUES (?, ?, ?, ?, 1)",
(sid, row["role"], row["content"], row["timestamp"]),
)
conn.commit()
conn.close()
def _sentinel_session(tmp_path, monkeypatch):
"""A session whose first row carries sentinel-encoded multimodal content."""
from api import models
sid = "sentineltest"
db = tmp_path / "state.db"
_make_state_db(db, sid, [
{"role": "user", "content": _sentinel(json.dumps(TEXT_AND_IMAGE)), "timestamp": 1000.0},
{"role": "assistant", "content": "plain reply", "timestamp": 2000.0},
])
monkeypatch.setattr(models, "_active_state_db_path", lambda: db, raising=False)
return models, sid
def test_transcript_read_decodes_sentinel_rows(tmp_path, monkeypatch):
"""The canonical projection hands the frontend structured content."""
models, sid = _sentinel_session(tmp_path, monkeypatch)
messages = models.get_state_db_session_messages(sid)
assert messages, "expected the fixture session to load"
assert messages[0]["content"] == TEXT_AND_IMAGE
# the base64 payload must not survive as literal transcript text
assert not isinstance(messages[0]["content"], str)
def test_regeneration_prefix_and_tail_keys_agree_for_a_sentinel_row(tmp_path, monkeypatch):
"""The same row keyed as prefix and as tail must produce one identity.
Decoding the projected tail while leaving prefix keys encoded would make
`_bounded_tail_snapshot_if_safe` reject the bounded path and re-read the
whole transcript, and could hide a genuine repeated recovered turn.
"""
models, sid = _sentinel_session(tmp_path, monkeypatch)
# floor above the sentinel row -> it is part of the prefix proof
as_prefix = models.get_state_db_regeneration_tail_snapshot(sid, 1500.0)
# floor below it -> the same row is part of the bounded tail
as_tail = models.get_state_db_regeneration_tail_snapshot(sid, 500.0)
assert as_prefix is not None and as_tail is not None
assert as_prefix["prefix_keys"], "sentinel row should sit in the prefix"
assert as_tail["tail_keys"], "sentinel row should sit in the tail"
assert as_prefix["prefix_keys"][0] == as_tail["tail_keys"][0]
def test_bounded_prefix_reader_agrees_with_the_projected_tail(tmp_path, monkeypatch):
"""The standalone prefix-key reader shares the tail's representation."""
models, sid = _sentinel_session(tmp_path, monkeypatch)
prefix_keys = models.get_state_db_session_message_keys_before_timestamp(sid, 1500.0)
tail = models.get_state_db_regeneration_tail_snapshot(sid, 500.0)
assert prefix_keys, "expected a prefix key for the sentinel row"
assert tail is not None and tail["tail_keys"]
assert prefix_keys[0] == tail["tail_keys"][0]
def test_unsupported_sentinel_shape_survives_the_read_path_as_text(tmp_path, monkeypatch):
"""A shape the UI cannot render stays a string end-to-end, not silently dropped."""
from api import models
sid = "unsupported"
db = tmp_path / "state.db"
raw = _sentinel(json.dumps({"type": "text", "text": "dict root"}))
_make_state_db(db, sid, [{"role": "user", "content": raw, "timestamp": 1000.0}])
monkeypatch.setattr(models, "_active_state_db_path", lambda: db, raising=False)
messages = models.get_state_db_session_messages(sid)
assert messages
assert messages[0]["content"] == raw
# --- re-gate finding 2: only decode what will actually render --------------
def test_whitespace_only_text_with_an_image_stays_raw():
payload = [
{"type": "text", "text": " \n\t "},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}},
]
raw = _sentinel(json.dumps(payload))
assert _decode_state_db_content(raw) == raw
def test_malformed_image_parts_stay_raw():
text = {"type": "text", "text": "caption"}
malformed = [
{"type": "image_url", "payload": "invalid"},
{"type": "image_url", "image_url": {"url": ""}},
{"type": "image_url", "image_url": {"href": "x"}},
{"type": "input_image"},
{"type": "image", "source": {"type": "base64", "data": "AA"}}, # no media_type
{"type": "image", "source": {"type": "url"}},
{"type": "image", "source": "not-a-dict"},
]
for bad in malformed:
raw = _sentinel(json.dumps([text, bad]))
assert _decode_state_db_content(raw) == raw, bad
def test_text_with_each_valid_image_payload_shape_decodes():
text = {"type": "text", "text": "caption"}
valid = [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}},
{"type": "image_url", "image_url": "https://example.test/a.png"},
{"type": "input_image", "image_url": "data:image/png;base64,AA"},
{"type": "input_image", "file_id": "file-123"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AA"}},
{"type": "image", "source": {"type": "url", "url": "https://example.test/a.png"}},
]
for good in valid:
payload = [text, good]
assert _decode_state_db_content(_sentinel(json.dumps(payload))) == payload, good
def test_image_only_row_stays_visible_through_the_read_path(tmp_path, monkeypatch):
from api import models
sid = "imageonly"
db = tmp_path / "state.db"
raw = _sentinel(json.dumps([{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}]))
_make_state_db(db, sid, [{"role": "user", "content": raw, "timestamp": 1000.0}])
monkeypatch.setattr(models, "_active_state_db_path", lambda: db, raising=False)
messages = models.get_state_db_session_messages(sid)
assert messages and messages[0]["content"] == raw
# --- re-gate finding 1: out-of-band identity, consistent across every key ---
from api.models import ( # noqa: E402
_matching_visible_duplicate,
_session_message_content_key,
merge_session_messages_append_only,
)
# Exactly what the previous in-band tag produced for TEXT_AND_IMAGE.
_OLD_INBAND_TOKEN = "\x00list:" + json.dumps(TEXT_AND_IMAGE, sort_keys=True, default=str)
def test_structured_identity_is_not_a_string():
"""Out of band: no message body can equal it, whatever it contains."""
assert not isinstance(_content_identity_for_key(TEXT_AND_IMAGE), str)
def test_scalar_imitating_a_structured_identity_collides_with_no_key():
rich = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1.0}
forged = {"role": "user", "content": _OLD_INBAND_TOKEN, "timestamp": 1.0}
assert _session_message_merge_key(rich) != _session_message_merge_key(forged)
assert _session_message_dedup_key(rich) != _session_message_dedup_key(forged)
assert _session_message_content_key(rich) != _session_message_content_key(forged)
assert _session_message_visible_key(rich) != _session_message_visible_key(forged)
def test_merge_keeps_the_rich_row_when_a_scalar_imitates_its_identity():
rich = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1000.0}
forged = {"role": "user", "content": _OLD_INBAND_TOKEN, "timestamp": 1000.0}
merged = merge_session_messages_append_only([forged], [rich])
assert any(m.get("content") == TEXT_AND_IMAGE for m in merged), merged
def test_non_list_scalars_key_exactly_as_on_master():
"""No silent dedup change for ordinary non-string content."""
assert _content_identity_for_key(42) == "42"
assert _content_identity_for_key(3.5) == "3.5"
assert _content_identity_for_key(True) == "True"
assert _content_identity_for_key({"a": 1}) == str({"a": 1})
assert _content_identity_for_key([]) == ""
msg = {"role": "user", "content": 42, "timestamp": 1.0}
assert "42" in _session_message_merge_key(msg)
def test_structured_visible_key_never_fuzzy_matches_a_scalar():
rich_key = _session_message_visible_key({"role": "user", "content": TEXT_AND_IMAGE})
canonical = _content_identity_for_key(TEXT_AND_IMAGE)[1]
for text in (canonical, "prefix " + canonical + " suffix", str(TEXT_AND_IMAGE)):
scalar_key = _session_message_visible_key({"role": "user", "content": text})
assert _matching_visible_duplicate(rich_key, {scalar_key}) is None
assert _matching_visible_duplicate(scalar_key, {rich_key}) is None
def test_merge_never_writes_an_identity_into_message_content():
rich = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1000.0}
reply = {"role": "assistant", "content": "ok", "timestamp": 1001.0}
merged = merge_session_messages_append_only([rich], [rich, reply])
for message in merged:
assert isinstance(message.get("content"), (str, list))
assert not isinstance(message.get("content"), tuple)
assert any(m.get("content") == TEXT_AND_IMAGE for m in merged)
def test_structured_content_is_serialised_once_per_merge_call(monkeypatch):
from api import models
calls = {"n": 0}
real = models._canonical_structured_content
def counting(content):
calls["n"] += 1
return real(content)
monkeypatch.setattr(models, "_canonical_structured_content", counting)
rich = {"role": "user", "content": list(TEXT_AND_IMAGE), "timestamp": 1000.0}
merge_session_messages_append_only([rich, rich], [rich])
assert calls["n"] == 1