fix(chat): preserve submitted image turns on stale recovery

When a dead stream is reconciled from state.db, retain the submitted
prompt and attachment in the sidecar while keeping the Agent-enriched
row in model context. Use one parent snapshot for full-session conflict
projection so the sidecar owner is stable across the display merge.

Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Assisted-by: Claude Code:claude-opus-5-5
This commit is contained in:
starship-s committed 2026-09-23 14:39:11 -06:00
1 parent 4f2bdd2dff
commit d07c60a92a
5 files changed
+224 -32

No files matched your search

@@ -433,11 +433,23 @@ def test_get_session_projects_parent_only_payload_conflict_without_losing_parent
response.update(payload=payload, status=status) or payload
),
)
original_get_session = routes.get_session
parent_loads = []
def get_session_with_fresh_parent(requested_id, metadata_only=False):
if requested_id == parent_id:
parent_session = models.Session.load(parent_id)
parent_loads.append(parent_session)
return parent_session
return original_get_session(requested_id, metadata_only=metadata_only)
monkeypatch.setattr(routes, "get_session", get_session_with_fresh_parent)
routes._handle_session_get(
None,
SimpleNamespace(path="/api/session", query=f"session_id={session_id}&resolve_model=0"),
)
assert len(parent_loads) == 1
assert response["status"] == 200
public_messages = response["payload"]["session"]["messages"]
mirror_rows = [message for message in public_messages if message.get("content") == mirror]
@@ -452,6 +464,9 @@ def test_get_session_projects_parent_only_payload_conflict_without_losing_parent
prefer_context=True,
state_messages=state_rows,
)
assert "Unrelated parent-only row" not in [
message.get("content") for message in replay_context
]
replay_rows = [
message for message in _sanitize_messages_for_agent(replay_context)
if message.get("content") == mirror
@@ -818,6 +818,83 @@ 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_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 +961,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 +971,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 +1007,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):