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:
1 parent
4f2bdd2dff
commit
d07c60a92a
5 files changed
+224
-32
No files matched your search
+77
-11
@@ -4033,6 +4033,68 @@ def _sync_sidecar_from_state_db_if_newer(session) -> bool:
|
||||
)
|
||||
if not state_messages:
|
||||
return False
|
||||
|
||||
# The Agent row is intentionally hidden from display while the saved
|
||||
# handoff proof is active. Materialize the exact WebUI-owned prompt
|
||||
# before reconciling that row away, so clearing pending state cannot
|
||||
# erase the only durable copy of the submitted text/attachments.
|
||||
pending_identity = _validated_webui_pending_user_timestamp_identity(
|
||||
locked,
|
||||
getattr(locked, '_webui_pending_user_timestamp_identity', None),
|
||||
)
|
||||
# When a legacy sidecar has no explicit context_messages, the shared
|
||||
# context reconciler falls back to messages. Capture that model-facing
|
||||
# merge before adding the display-only WebUI owner row below.
|
||||
merged_context = (
|
||||
reconciled_state_db_messages_for_session(
|
||||
locked,
|
||||
prefer_context=True,
|
||||
state_messages=state_messages,
|
||||
)
|
||||
if pending_identity is not None
|
||||
else None
|
||||
)
|
||||
if pending_identity is not None:
|
||||
pending_timestamp = pending_identity[1]
|
||||
pending_text = locked.pending_user_message
|
||||
pending_source = getattr(locked, 'pending_user_source', None) or 'webui'
|
||||
pending_attachments = list(getattr(locked, 'pending_attachments', None) or [])
|
||||
pending_row = {
|
||||
'role': 'user',
|
||||
'content': pending_text,
|
||||
'timestamp': pending_timestamp,
|
||||
}
|
||||
stamp_message_source(pending_row, pending_source)
|
||||
if str(pending_source or '').strip().lower() == 'fork':
|
||||
pending_row['_fork_child_turn'] = locked.session_id
|
||||
if pending_attachments:
|
||||
pending_row['attachments'] = pending_attachments
|
||||
|
||||
existing_pending = next((
|
||||
message for message in locked_messages
|
||||
if isinstance(message, dict)
|
||||
and (
|
||||
_message_exact_timestamp_details(message)
|
||||
== (pending_timestamp, True)
|
||||
and (message.get('_source') or 'webui') == pending_source
|
||||
and _message_matches_pending_text(message, pending_text)
|
||||
)
|
||||
), None)
|
||||
if existing_pending is not None:
|
||||
# A prior eager checkpoint already owns this exact proved turn;
|
||||
# refresh it from the still-authoritative pending fields.
|
||||
existing_pending.update(pending_row)
|
||||
if pending_attachments:
|
||||
existing_pending['attachments'] = pending_attachments
|
||||
else:
|
||||
existing_pending.pop('attachments', None)
|
||||
else:
|
||||
# The proof makes this submitted row authoritative even if a
|
||||
# clock adjustment places it before the surviving sidecar tail.
|
||||
if not _insert_state_message_chronologically(locked_messages, pending_row):
|
||||
locked_messages.insert(0, pending_row)
|
||||
locked.messages = locked_messages
|
||||
|
||||
merged_messages = reconciled_state_db_messages_for_session(
|
||||
locked,
|
||||
state_messages=state_messages,
|
||||
@@ -4043,11 +4105,12 @@ def _sync_sidecar_from_state_db_if_newer(session) -> bool:
|
||||
# recover — leave the sidecar untouched rather than rewriting in place.
|
||||
if len(merged_messages) <= locked_count:
|
||||
return False
|
||||
merged_context = reconciled_state_db_messages_for_session(
|
||||
locked,
|
||||
prefer_context=True,
|
||||
state_messages=state_messages,
|
||||
)
|
||||
if merged_context is None:
|
||||
merged_context = reconciled_state_db_messages_for_session(
|
||||
locked,
|
||||
prefer_context=True,
|
||||
state_messages=state_messages,
|
||||
)
|
||||
|
||||
# Mutate + persist the freshly-loaded, locked object. Because we hold the
|
||||
# lock and reloaded under it, this save cannot clobber a concurrent
|
||||
@@ -10787,12 +10850,15 @@ def _project_native_image_payload_conflicts_for_display(
|
||||
if row_id_valid and row_id is not None and timestamp_valid
|
||||
else None
|
||||
)
|
||||
if (
|
||||
conflict is not None
|
||||
and message is not conflict[1]
|
||||
and _message_identity_compatible(message, conflict[0])
|
||||
):
|
||||
continue
|
||||
if conflict is not None and _message_identity_compatible(message, conflict[0]):
|
||||
incoming, sidecar_owner = conflict
|
||||
is_sidecar_owner = (
|
||||
_message_identity_compatible(message, sidecar_owner)
|
||||
and _session_message_api_content_key(message)
|
||||
== _session_message_api_content_key(sidecar_owner)
|
||||
)
|
||||
if not is_sidecar_owner:
|
||||
continue
|
||||
visible_messages.append(message)
|
||||
return visible_messages
|
||||
|
||||
|
||||
+39
-20
@@ -10065,7 +10065,35 @@ def _merged_session_messages_for_display(session, cli_messages=None) -> list:
|
||||
|
||||
|
||||
|
||||
def _merged_webui_lineage_messages_for_display(session, messages=None) -> list:
|
||||
_LINEAGE_PARENT_SESSION_UNSET = object()
|
||||
|
||||
|
||||
def _webui_lineage_parent_session_for_display(session):
|
||||
"""Load the immediate parent only for display-eligible continuations."""
|
||||
parent_id = str(getattr(session, "parent_session_id", "") or "").strip()
|
||||
if not parent_id:
|
||||
return None
|
||||
if (
|
||||
str(getattr(session, "compression_recovery_source_session_id", "") or "").strip()
|
||||
and str(getattr(session, "compression_recovery_action", "") or "").strip()
|
||||
):
|
||||
return None
|
||||
source = str(getattr(session, "session_source", "") or "").strip().lower()
|
||||
relationship = str(getattr(session, "relationship_type", "") or "").strip().lower()
|
||||
if source == "fork" or relationship == "child_session":
|
||||
return None
|
||||
try:
|
||||
return get_session(parent_id, metadata_only=False)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _merged_webui_lineage_messages_for_display(
|
||||
session,
|
||||
messages=None,
|
||||
*,
|
||||
parent_session=_LINEAGE_PARENT_SESSION_UNSET,
|
||||
) -> list:
|
||||
"""Include immediate parent-only rows when a WebUI continuation sidecar is partial.
|
||||
|
||||
Compression/continuation sessions should render as one conversation. Most
|
||||
@@ -10076,23 +10104,9 @@ def _merged_webui_lineage_messages_for_display(session, messages=None) -> list:
|
||||
subset of their parent.
|
||||
"""
|
||||
primary_messages = list(messages if messages is not None else (getattr(session, "messages", []) or []))
|
||||
parent_id = str(getattr(session, "parent_session_id", "") or "").strip()
|
||||
if not parent_id:
|
||||
return primary_messages
|
||||
if (
|
||||
str(getattr(session, "compression_recovery_source_session_id", "") or "").strip()
|
||||
and str(getattr(session, "compression_recovery_action", "") or "").strip()
|
||||
):
|
||||
return primary_messages
|
||||
source = str(getattr(session, "session_source", "") or "").strip().lower()
|
||||
relationship = str(getattr(session, "relationship_type", "") or "").strip().lower()
|
||||
if source == "fork" or relationship == "child_session":
|
||||
return primary_messages
|
||||
try:
|
||||
parent = get_session(parent_id, metadata_only=False)
|
||||
except Exception:
|
||||
return primary_messages
|
||||
parent_messages = list(getattr(parent, "messages", []) or [])
|
||||
if parent_session is _LINEAGE_PARENT_SESSION_UNSET:
|
||||
parent_session = _webui_lineage_parent_session_for_display(session)
|
||||
parent_messages = list(getattr(parent_session, "messages", []) or [])
|
||||
if not parent_messages:
|
||||
return primary_messages
|
||||
if _messages_start_with_visible_prefix(primary_messages, parent_messages):
|
||||
@@ -10572,7 +10586,6 @@ from api.models import (
|
||||
new_session,
|
||||
all_sessions,
|
||||
title_from,
|
||||
_write_session_index,
|
||||
SESSION_INDEX_FILE,
|
||||
_active_state_db_path,
|
||||
load_projects,
|
||||
@@ -13333,9 +13346,11 @@ def _handle_session_get(handler, parsed) -> bool:
|
||||
state_db_messages,
|
||||
)
|
||||
sidecar_messages = _webui_sidecar_lineage_messages_for_display(s)
|
||||
lineage_parent = _webui_lineage_parent_session_for_display(s)
|
||||
projection_sidecar_messages = _merged_webui_lineage_messages_for_display(
|
||||
s,
|
||||
sidecar_messages,
|
||||
parent_session=lineage_parent,
|
||||
)
|
||||
_all_msgs = merge_session_messages_append_only(
|
||||
sidecar_messages,
|
||||
@@ -13343,7 +13358,11 @@ def _handle_session_get(handler, parsed) -> bool:
|
||||
truncation_watermark=getattr(s, "truncation_watermark", None),
|
||||
truncation_boundary=getattr(s, "truncation_boundary", None),
|
||||
)
|
||||
_all_msgs = _merged_webui_lineage_messages_for_display(s, _all_msgs)
|
||||
_all_msgs = _merged_webui_lineage_messages_for_display(
|
||||
s,
|
||||
_all_msgs,
|
||||
parent_session=lineage_parent,
|
||||
)
|
||||
_all_msgs = _project_native_image_payload_conflicts_for_display(
|
||||
projection_sidecar_messages,
|
||||
state_db_messages,
|
||||
|
||||
Reference in new issue
Block a user