Merge PR #7754 from starship-s/fix/image-turn-display-context

This commit is contained in:
nesquena-hermes committed 2026-09-24 10:54:56 +00:00
commit 0adc1f6e4f
11 files changed
+3534 -156

No files matched your search

@@ -0,0 +1,65 @@
"""#7754: a marked native-image state row that replays its sidecar row must consume that replay slot.
Before the fix, the row-id fast path for a marked native-image mirror row `continue`d without
advancing the replay index. The edited checkpoint then never read as consumed, and a rerun reply
that exists only in state.db was truncated. Master (no marker branch) keeps that reply.
"""
import api.models as models
def _merge(*, marked):
field = models._WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD
image_row = {
"role": "user",
"content": "look",
"timestamp": 860.0,
"_state_db_row_id": 42,
"api_content": "PAYLOAD-A",
}
sidecar = [
{"role": "user", "content": "u1", "timestamp": 100.0},
{"role": "assistant", "content": "a1", "timestamp": 101.0},
dict(image_row),
{"role": "assistant", "content": "a_img", "timestamp": 861.0},
{"role": "user", "content": "uE", "timestamp": 900.0},
]
state_image_row = dict(image_row)
if marked:
state_image_row[field] = True
state = [
{"role": "user", "content": "u1", "timestamp": 100.0},
{"role": "assistant", "content": "a1", "timestamp": 101.0},
state_image_row,
{"role": "assistant", "content": "a_img", "timestamp": 861.0},
{"role": "user", "content": "u_old", "timestamp": 870.0},
{"role": "assistant", "content": "a_old", "timestamp": 871.0},
{"role": "user", "content": "uE", "timestamp": 900.0},
{"role": "assistant", "content": "aE", "timestamp": 901.0},
]
return models.merge_session_messages_append_only(
sidecar,
state,
truncation_watermark=900.0,
truncation_boundary=861.0,
incoming_provenance="state_db",
)
def _contents(rows):
return [row.get("content") for row in rows]
def test_marked_image_row_keeps_state_only_reply_after_edited_checkpoint():
merged = _merge(marked=True)
assert _contents(merged) == ["u1", "a1", "look", "a_img", "uE", "aE"]
def test_marked_and_unmarked_image_rows_merge_identically():
assert _contents(_merge(marked=True)) == _contents(_merge(marked=False))
def test_removed_rows_between_boundary_and_watermark_stay_out():
contents = _contents(_merge(marked=True))
assert "u_old" not in contents
assert "a_old" not in contents
+7 -6
View File
@@ -41,12 +41,13 @@ class TestRootWorkspaceUnblocked:
assert "'/etc'" in src or 'Path("/etc")' in src or "Path('/etc')" in src
assert "'/proc'" in src or 'Path("/proc")' in src or "Path('/proc')" in src
def test_split_guard_present(self):
src = read("api/streaming.py")
assert "'\\n\\n[Attached files:' in msg_text" in src, (
"base_text split must guard against missing '[Attached files:' "
"to avoid empty-string on plain messages"
)
def test_attached_files_suffix_is_optional(self):
from api.models import _strip_attached_files_marker
assert _strip_attached_files_marker("Plain message") == "Plain message"
assert _strip_attached_files_marker(
"Question\n\n[Attached files: photo.png]"
) == "Question"
# ── Group B: custom_providers visibility ─────────────────────────────────────
+8 -1
View File
@@ -1068,6 +1068,7 @@ def test_auth_self_heal_refreshes_revision_after_first_agent_persists_user(
messages=prior_messages,
context_messages=prior_messages,
)
_session.pending_user_source = "webui"
revisions = []
class AuthThenRecoverAgent:
@@ -1080,8 +1081,14 @@ def test_auth_self_heal_refreshes_revision_after_first_agent_persists_user(
self._last_error = None
self.stream_delta_callback = _kwargs.get("stream_delta_callback")
def run_conversation(self, **kwargs):
def run_conversation(self, persist_user_timestamp=None, **kwargs):
type(self).runs += 1
assert persist_user_timestamp == 10.0
persisted = Session.load(sid)
assert persisted._webui_pending_user_timestamp_identity == (
stream_id,
persist_user_timestamp,
)
revisions.append(kwargs["conversation_history_revision"])
history = list(kwargs.get("conversation_history") or [])
if type(self).runs == 1:
File diff suppressed because it is too large. Load diff
@@ -818,6 +818,169 @@ def test_get_session_syncs_sidecar_from_newer_state_db_even_when_stream_not_term
assert reloaded.messages[-1]["content"] == "latest live progress"
def test_get_session_sync_keeps_pending_image_prompt_when_agent_row_is_proven(monkeypatch, hermes_home):
sid = "state_newer_pending_image_sid"
stream_id = "dead_pending_image_stream"
timestamp = time.time() - models._REPAIR_STALE_PENDING_GRACE_SECONDS - 5
prompt = "Describe the attached image"
attachment = {"name": "sample.png", "mime": "image/png", "is_image": True}
agent_only_note = "Agent-only recall note"
agent_user_content = [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,YWJj"}},
{"type": "text", "text": agent_only_note},
]
previous_messages = [
{"role": "user", "content": "old question", "timestamp": timestamp - 2},
{"role": "assistant", "content": "old answer", "timestamp": timestamp - 1},
]
state_messages = [
*previous_messages,
{"role": "user", "content": agent_user_content, "timestamp": timestamp},
{"role": "assistant", "content": "recovered assistant tail", "timestamp": timestamp + 1},
]
s = Session(
session_id=sid,
title="Pending image recovery",
messages=previous_messages,
context_messages=previous_messages,
active_stream_id=stream_id,
pending_user_message=prompt,
pending_attachments=[attachment],
pending_started_at=timestamp,
pending_user_source="webui",
_webui_pending_user_timestamp_identity=(stream_id, timestamp),
)
s.save()
models.SESSIONS.pop(sid, None)
monkeypatch.setattr(
models,
"get_state_db_session_summary",
lambda sid_arg, profile=None: {
"message_count": len(state_messages),
"last_message_at": timestamp + 1,
},
)
monkeypatch.setattr(
models,
"get_state_db_session_messages",
lambda sid_arg, **kwargs: list(state_messages),
)
models.get_session(sid)
reloaded = Session.load(sid)
submitted_rows = [
message for message in reloaded.messages
if message.get("role") == "user" and message.get("content") == prompt
]
assert len(submitted_rows) == 1
assert submitted_rows[0]["timestamp"] == timestamp
assert submitted_rows[0]["attachments"] == [attachment]
assert reloaded.messages[-1] == state_messages[-1]
assert agent_only_note not in str(reloaded.messages)
context_rows = models.reconciled_state_db_messages_for_session(
reloaded,
prefer_context=True,
state_messages=state_messages,
)
enriched_rows = [
message for message in context_rows
if message.get("role") == "user" and message.get("timestamp") == timestamp
]
assert len(enriched_rows) == 1
assert enriched_rows[0]["content"] == agent_user_content
assert agent_only_note in str(enriched_rows[0]["content"])
def test_state_db_pending_image_row_alone_does_not_skip_stale_journal_recovery(
monkeypatch, hermes_home,
):
sid = "state_pending_image_without_output_sid"
stream_id = "dead_pending_image_without_output_stream"
timestamp = time.time() - models._REPAIR_STALE_PENDING_GRACE_SECONDS - 5
prompt = "Describe the attached image"
attachment = {"name": "sample.png", "mime": "image/png", "is_image": True}
old_messages = [
{"role": "user", "content": "old question", "timestamp": timestamp - 2},
{"role": "assistant", "content": "old answer", "timestamp": timestamp - 1},
]
state_messages = [
*old_messages,
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,YWJj"}},
],
"timestamp": timestamp,
},
]
session = Session(
session_id=sid,
title="Pending image without output",
messages=old_messages,
context_messages=old_messages,
active_stream_id=stream_id,
pending_user_message=prompt,
pending_attachments=[attachment],
pending_started_at=timestamp,
pending_user_source="webui",
_webui_pending_user_timestamp_identity=(stream_id, timestamp),
)
session.save()
models.SESSIONS[sid] = session
monkeypatch.setattr(
models,
"get_state_db_session_summary",
lambda sid_arg, profile=None: {
"message_count": len(state_messages),
"last_message_at": timestamp,
},
)
monkeypatch.setattr(
models,
"get_state_db_session_messages",
lambda sid_arg, **kwargs: list(state_messages),
)
append_run_event(sid, stream_id, "token", {"text": "Recovered partial text."})
# Cached get_session runs state.db self-heal, then the real stale-stream
# cleanup must still get the pending turn so it can replay the run journal.
loaded = models.get_session(sid)
assert loaded is session
assert loaded.active_stream_id == stream_id
assert loaded.pending_user_message == prompt
assert loaded.pending_attachments == [attachment]
import api.routes as routes
assert routes._clear_stale_stream_state(loaded) is True
reloaded = Session.load(sid)
submitted_rows = [
message for message in reloaded.messages
if message.get("role") == "user" and message.get("content") == prompt
]
assert len(submitted_rows) == 1
assert submitted_rows[0]["attachments"] == [attachment]
assert any(
message.get("_recovered_from_run_journal")
and "Recovered partial text." in message.get("content", "")
for message in reloaded.messages
)
markers = [
message for message in reloaded.messages
if message.get("_error") and message.get("type") == "interrupted"
]
assert len(markers) == 1
assert "**Response interrupted.**" in markers[0]["content"]
assert "partial output above was recovered" in markers[0]["content"]
def test_get_session_does_not_sync_while_stream_is_still_live(monkeypatch):
"""A still-running worker owns its own writeback; do not race it.
@@ -884,6 +1047,7 @@ def test_sync_save_failure_does_not_mutate_session(monkeypatch):
"""
sid = "state_sync_save_fail_sid"
stream_id = "stream_save_fail_no_done"
attachment = {"name": "sample.png", "mime": "image/png", "is_image": True}
s = Session(
session_id=sid,
title="Save failure",
@@ -893,7 +1057,10 @@ def test_sync_save_failure_does_not_mutate_session(monkeypatch):
],
active_stream_id=stream_id,
pending_user_message="new request",
pending_attachments=[attachment],
pending_started_at=102.0,
pending_user_source="webui",
_webui_pending_user_timestamp_identity=(stream_id, 102.0),
)
s.save()
@@ -926,7 +1093,13 @@ def test_sync_save_failure_does_not_mutate_session(monkeypatch):
assert result is False
assert s.active_stream_id == stream_id
assert s.pending_user_message == "new request"
assert s.pending_attachments == [attachment]
assert [m["content"] for m in s.messages] == ["old question", "old answer"]
reloaded = Session.load(sid)
assert reloaded.active_stream_id == stream_id
assert reloaded.pending_user_message == "new request"
assert reloaded.pending_attachments == s.pending_attachments
assert [m["content"] for m in reloaded.messages] == ["old question", "old answer"]
def test_sync_persists_recovered_state_db_tail_when_stream_dead(monkeypatch):