From 2262444806e38061a3a8b2b9362a0a590da0b55d Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:59:13 -0600 Subject: [PATCH 01/16] fix(chat): keep image-turn context out of user bubbles Preserve the submitted image turn as the display owner while retaining rich Agent context for model replay and suppressing its durable scalar mirror from recovered WebUI transcripts. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna Assisted-by: Claude Code:claude-opus-5-5 --- api/gateway_chat.py | 3 + api/helpers.py | 2 + api/models.py | 223 ++++- api/routes.py | 51 ++ api/streaming.py | 309 ++++++- .../test_native_image_turn_display_context.py | 801 ++++++++++++++++++ 6 files changed, 1356 insertions(+), 33 deletions(-) create mode 100644 tests/test_native_image_turn_display_context.py diff --git a/api/gateway_chat.py b/api/gateway_chat.py index c66175d5..e155329f 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -1358,6 +1358,9 @@ def _run_gateway_chat_streaming( s.context_messages, str(msg_text or ""), source=pending_source, + verification_nudge_provenance={ + "active_turn_identity": active_turn_identity, + }, ) except Exception: logger.debug("Failed to merge gateway display transcript", exc_info=True) diff --git a/api/helpers.py b/api/helpers.py index 1984bf28..f0d2cba2 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -24,6 +24,8 @@ _PUBLIC_MESSAGE_INTERNAL_FIELDS = frozenset({ "_active_turn_token", "_active_turn_user", "_fork_child_turn", + "_webui_trusted_agent_input_text", + "_webui_unmatched_native_image_mirror", }) diff --git a/api/models.py b/api/models.py index dd775c68..056cd1c8 100644 --- a/api/models.py +++ b/api/models.py @@ -8430,8 +8430,8 @@ def _project_state_db_message(row, available, id_col, optional): Shared by ``get_state_db_session_messages`` and the regeneration single-snapshot helper so the bounded tail can never drift from the canonical reader: JSON-decode content/tool_calls/reasoning payloads, omit - empty fields, keep durable row id private (``_state_db_row_id`` only for - real Agent api_content replays), and apply ``tool_name → name``. + empty fields, keep durable row id private for provider replays and native + image projections, and apply ``tool_name → name``. """ msg = { 'role': row['role'], @@ -8447,11 +8447,21 @@ def _project_state_db_message(row, available, id_col, optional): if col in {'tool_calls', 'reasoning_details', 'codex_reasoning_items', 'codex_message_items'}: value = _json_loads_if_string(value) msg[col] = value + native_image_projection = ( + msg.get('role') == 'user' + and isinstance(msg.get('content'), str) + and '[screenshot]' in msg['content'] + ) if ( id_col and row['id'] is not None - and isinstance(msg.get('api_content'), str) - and msg['api_content'] + and ( + native_image_projection + or ( + isinstance(msg.get('api_content'), str) + and msg['api_content'] + ) + ) ): msg['_state_db_row_id'] = row['id'] if msg.get('role') == 'tool' and msg.get('tool_name') and not msg.get('name'): @@ -9120,6 +9130,8 @@ def _session_message_key_with_sidecar(base_key: tuple, msg: dict) -> tuple: _SESSION_MESSAGE_IMAGE_PART_TYPES = {"image", "image_url", "input_image"} +_WEBUI_TRUSTED_AGENT_INPUT_FIELD = "_webui_trusted_agent_input_text" +_WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD = "_webui_unmatched_native_image_mirror" def _agent_durable_multimodal_content(msg: dict) -> str | None: @@ -9200,6 +9212,178 @@ def _session_message_multimodal_mirror_key( ) +def _native_image_leading_text(message): + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list): + return None + parts = [] + for part in content: + if not isinstance(part, dict): + return None + part_type = str(part.get("type") or "").lower() + if part_type in _SESSION_MESSAGE_IMAGE_PART_TYPES: + return "\n".join(parts) + if part_type not in {"", "text", "input_text", "output_text"}: + return None + text = part.get("text", part.get("input_text", part.get("output_text", ""))) + if not isinstance(text, str): + return None + parts.append(text) + return None + + +def _suppress_native_image_display_mirrors( + session, + state_messages, + *, + suppress_api_content=True, +): + """Drop exact Agent image projections backed by one token-paired WebUI turn.""" + display_messages = getattr(session, "messages", None) or [] + context_messages = getattr(session, "context_messages", None) or [] + if not display_messages or not context_messages or not state_messages: + return state_messages + + display_by_token = collections.defaultdict(list) + context_by_token = collections.defaultdict(list) + for message in display_messages: + if isinstance(message, dict) and message.get("_active_turn_token"): + display_by_token[message["_active_turn_token"]].append(message) + for message in context_messages: + if isinstance(message, dict) and message.get("_active_turn_token"): + context_by_token[message["_active_turn_token"]].append(message) + + display_row_id_counts = collections.Counter() + context_row_id_counts = collections.Counter() + for messages, counts in ( + (display_messages, display_row_id_counts), + (context_messages, context_row_id_counts), + ): + for message in messages: + row_id, valid = _state_db_row_identity_details(message) + if valid and row_id is not None: + counts[row_id] += 1 + + from api.streaming import _submitted_user_text_matches + + mirrors = collections.defaultdict(list) + for token, contexts in context_by_token.items(): + displays = display_by_token.get(token, []) + for context in contexts: + trusted_input = context.get(_WEBUI_TRUSTED_AGENT_INPUT_FIELD) + leading_text = _native_image_leading_text(context) + if ( + not isinstance(trusted_input, str) + or leading_text is None + or not _submitted_user_text_matches(leading_text, trusted_input) + ): + continue + key = _session_message_multimodal_mirror_key( + context, + require_image_parts=True, + ) + if key is None: + continue + display = displays[0] if len(contexts) == len(displays) == 1 else None + display_link_valid = False + if display is not None and display.get("role") == "user": + context_ts, context_ts_valid = _message_exact_timestamp_details(context) + display_ts, display_ts_valid = _message_exact_timestamp_details(display) + display_link_valid = ( + context_ts_valid + and display_ts_valid + and context_ts is not None + and display_ts == context_ts + and _message_private_identity_compatible(display, context) + ) + # Keep trusted rich candidates even when display linkage is + # ambiguous, so a matching scalar row is preserved unless its + # durable row id proves it is the exact Agent projection. + mirrors[key].append((context, display, display_link_valid)) + if not mirrors: + return state_messages + + row_id_counts = collections.Counter() + stable_id_counts = collections.Counter() + state_mirror_keys = [] + for message in state_messages: + if not isinstance(message, dict): + state_mirror_keys.append(None) + continue + row_id, row_id_valid = _state_db_row_identity_details(message) + stable_id, stable_id_valid = _stable_message_identity_details(message) + if row_id_valid and row_id is not None: + row_id_counts[row_id] += 1 + if stable_id_valid and stable_id is not None: + stable_id_counts[stable_id] += 1 + state_mirror_keys.append( + _session_message_multimodal_mirror_key( + message, + require_scalar_mirror=True, + ) + ) + + suppress = set() + marked = {} + for index, key in enumerate(state_mirror_keys): + if key is None or key not in mirrors: + continue + message = state_messages[index] + row_id, row_id_valid = _state_db_row_identity_details(message) + stable_id, stable_id_valid = _stable_message_identity_details(message) + contexts = mirrors[key] + matched = None + preserve_distinct_row = False + if len(contexts) == 1: + context, display, display_link_valid = contexts[0] + context_row_id, context_row_id_valid = _state_db_row_identity_details(context) + display_row_id, display_row_id_valid = _state_db_row_identity_details(display) + linked_context_row = ( + display is not None + and display_link_valid + and context_row_id_valid + and display_row_id_valid + and context_row_id is not None + and context_row_id == display_row_id + and context_row_id_counts[context_row_id] == 1 + and display_row_id_counts[display_row_id] == 1 + ) + if ( + linked_context_row + and (not row_id_valid or row_id is None or row_id != context_row_id) + and (row_id is None or row_id_counts[row_id] == 1) + and stable_id_valid + and (stable_id is None or stable_id_counts[stable_id] == 1) + ): + preserve_distinct_row = True + if ( + linked_context_row + and row_id_valid + and row_id is not None + and context_row_id == row_id + and row_id_counts[row_id] == 1 + and stable_id_valid + and (stable_id is None or stable_id_counts[stable_id] == 1) + and _message_private_identity_compatible(context, message) + ): + matched = context + if ( + matched is not None + and (suppress_api_content or not _session_message_api_content_key(message)) + ): + suppress.add(index) + elif preserve_distinct_row: + marked[index] = { + **message, + _WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD: True, + } + return [ + marked.get(index, message) + for index, message in enumerate(state_messages) + if index not in suppress + ] + + # Per-call memo of structured-content identities. Reconciliation derives merge, # dedup, content and visible keys for every message, several per source, so a # large multimodal payload would otherwise be serialised once per key. The memo @@ -10789,14 +10973,36 @@ def _merge_session_messages_append_only_impl( and boundary_ts is not None and boundary_ts < watermark_timestamp ) - for msg in state_messages: + for source_message in state_messages: + preserve_native_image_row = ( + isinstance(source_message, dict) + and source_message.get(_WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD) is True + ) + msg = ( + { + key: value + for key, value in source_message.items() + if key != _WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD + } + if preserve_native_image_row + else source_message + ) timestamp = _message_timestamp_as_float(msg) key = _cached_message_key(msg, "merge") dedup_key = _cached_message_key(msg, "dedup") visible_key = _cached_message_key(msg, "visible_state") content_key = _cached_message_key(msg, "content_state") + if preserve_native_image_row: + if not _insert_state_message_chronologically(merged_messages, msg): + merged_messages.append(msg) + seen_message_keys.add(key) + seen_dedup_keys.add(dedup_key) + seen_content_keys.add(content_key) + seen_visible_keys.add(visible_key) + _remember_merged_message(msg, source="state") + continue multimodal_mirror_key = ( - state_multimodal_mirror_keys.get(id(msg)) + state_multimodal_mirror_keys.get(id(source_message)) if sidecar_multimodal_mirrors else None ) @@ -11153,6 +11359,11 @@ def reconciled_state_db_messages_for_session( state_messages = state_result.messages else: state_messages = state_result + state_messages = _suppress_native_image_display_mirrors( + session, + state_messages, + suppress_api_content=not using_context_messages, + ) if prefer_context and local_messages: if using_context_messages: sidecar_messages = getattr(session, 'messages', None) or [] diff --git a/api/routes.py b/api/routes.py index 5f5893ba..4c8b9fc5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -9296,6 +9296,12 @@ def _limited_webui_messages_for_display_with_sidecar( else: sidecar_messages = list(sidecar_messages or []) state_db_messages = list(state_db_messages or []) + if not state_db_messages: + return sidecar_messages + state_db_messages = _suppress_native_image_display_mirrors( + session, + state_db_messages, + ) if not state_db_messages: return sidecar_messages # NOTE: do not short-circuit to the sidecar when state.db has no strictly @@ -10574,6 +10580,7 @@ from api.models import ( get_state_db_session_message_keys_before_timestamp, get_state_db_session_summary, merge_session_messages_append_only, + _suppress_native_image_display_mirrors, _reconcile_api_content_sidecars, _enrich_sidebar_lineage_metadata, _active_stream_ids, @@ -13314,6 +13321,10 @@ def _handle_session_get(handler, parsed) -> bool: msg_before=msg_before, ) else: + state_db_messages = _suppress_native_image_display_mirrors( + s, + state_db_messages, + ) _all_msgs = merge_session_messages_append_only( _webui_sidecar_lineage_messages_for_display(s), state_db_messages, @@ -25024,10 +25035,12 @@ def _handle_chat_sync(handler, body): _active_turn_boundary, _assign_stable_message_ids, _dedupe_replayed_context_messages, + _find_active_turn_checkpoint_index, _merge_display_messages_after_agent_result, _resolve_active_turn_authority, _restore_display_reasoning_metadata, _restore_reasoning_metadata_before_boundary, + _settle_current_turn_boundary, _sanitize_messages_for_agent, _compact_session_image_parts_for_persistence, _context_messages_for_new_turn, @@ -25092,6 +25105,33 @@ def _handle_chat_sync(handler, body): result=result, agent=agent, ) + if ( + isinstance(_active_turn_identity, dict) + and _active_turn_identity.get("agent_turn_boundary_resolved") is True + and not _active_turn_identity.get("token") + ): + _active_image_index = _find_active_turn_checkpoint_index( + _result_messages, + _previous_context_messages, + _active_turn_identity, + msg, + ) + _active_image_content = ( + _result_messages[_active_image_index].get("content") + if _active_image_index is not None + else None + ) + if isinstance(_active_image_content, list) and any( + isinstance(part, dict) + and part.get("type") in {"image", "image_url", "input_image"} + for part in _active_image_content + ): + from api.process_event_utils import build_active_turn_token + + _active_turn_identity["token"] = build_active_turn_token( + f"sync:{s.session_id}:{_active_turn_identity['turn_id']}", + time.time(), + ) _turn_boundary = _active_turn_boundary( _result_messages, _previous_context_messages, _active_turn_identity, msg, ) @@ -25110,6 +25150,14 @@ def _handle_chat_sync(handler, body): _next_context_messages, msg, ) + if _active_turn_identity.get("token"): + _next_context_messages = _settle_current_turn_boundary( + _previous_context_messages, + _next_context_messages, + _active_turn_identity, + msg, + getattr(s, "pending_user_source", None) or "webui", + ) s.context_messages = _next_context_messages s.messages = _merge_display_messages_after_agent_result( _previous_messages, @@ -25119,6 +25167,9 @@ def _handle_chat_sync(handler, body): ), msg, source=getattr(s, "pending_user_source", None) or "webui", + verification_nudge_provenance={ + "active_turn_identity": _active_turn_identity, + }, ) _compact_session_image_parts_for_persistence(s) # Only auto-generate title when still default; preserves user renames diff --git a/api/streaming.py b/api/streaming.py index 4dfa5b63..b224dbe2 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -75,7 +75,10 @@ from api.turn_journal import append_turn_journal_event_for_stream from api.usage import prompt_cache_hit_percent from api.models import ( StateDBSessionMessagesSnapshot, + _WEBUI_TRUSTED_AGENT_INPUT_FIELD, _is_empty_partial_activity_message, + _message_exact_timestamp_details, + _message_private_identity_compatible, _evict_sessions_over_cap, clear_process_wakeup_pause, get_state_db_session_messages, @@ -2076,6 +2079,53 @@ def _mark_active_turn_checkpoint_in_history(messages, identity, msg_text, *, all return messages, True +def _active_turn_user_text_matches(message, expected_text): + """Validate the Agent's declared user row against the submitted prompt.""" + if not isinstance(message, dict) or message.get('role') != 'user' or not isinstance(expected_text, str): + return False + content = message.get('content') + if isinstance(content, list): + leading_text = [] + for part in content: + if not isinstance(part, dict): + return False + part_type = str(part.get('type') or '').lower() + if part_type in {'image', 'image_url', 'input_image'}: + break + if part_type not in {'', 'text', 'input_text', 'output_text'}: + return False + text = part.get('text', part.get('input_text', part.get('output_text', ''))) + if not isinstance(text, str): + return False + leading_text.append(text) + if not content: + return False + actual_text = '\n'.join(leading_text) + elif isinstance(content, str): + actual_text = content + else: + return False + return _submitted_user_text_matches(actual_text, expected_text) + + +def _submitted_user_text_matches(actual_text, expected_text): + """Match exact prompt text with at most its generated workspace prefix.""" + if actual_text == expected_text: + return True + if not isinstance(actual_text, str) or not isinstance(expected_text, str): + return False + if not actual_text.endswith(expected_text): + return False + prefix = actual_text[:-len(expected_text)] if expected_text else actual_text + return bool( + prefix + and ( + _WORKSPACE_PREFIX_RE.fullmatch(prefix) + or _LEGACY_WORKSPACE_PREFIX_RE.fullmatch(prefix) + ) + ) + + def _owner_projection_current_turn_row(messages, identity): messages = list(messages or []) if not isinstance(identity, dict): @@ -2112,16 +2162,19 @@ def _find_active_turn_checkpoint_index(result_messages, previous_context, identi return idx if not _active_turn_boundary_is_valid(identity): return None - expected_text = identity.get('text') if identity.get('text') is not None else msg_text idx = identity['current_turn_user_idx'] if idx < 0 or idx >= len(result_messages): return None message = result_messages[idx] + expected_text = identity.get('text') if identity.get('text') is not None else msg_text + if _active_turn_user_text_matches(message, expected_text): + return idx + trusted_agent_input = identity.get('trusted_agent_input_text') if ( - isinstance(message, dict) - and message.get('role') == 'user' - and _normalize_user_text(_message_text(message.get('content'))) - == _normalize_user_text(expected_text) + identity.get('text') == msg_text + and isinstance(trusted_agent_input, str) + and trusted_agent_input != expected_text + and _active_turn_user_text_matches(message, trusted_agent_input) ): return idx return None @@ -2210,17 +2263,19 @@ def _settle_current_turn_boundary(previous_context, result_messages, identity, m ) if _checkpoint_idx is not None: existing_checkpoint = result_messages[_checkpoint_idx] - if isinstance(identity.get('checkpoint'), dict): - retained_checkpoint = _materialize_active_turn_user(identity, msg_text, source) - if ( - retained_checkpoint.get('id') is None - and isinstance(existing_checkpoint, dict) - and existing_checkpoint.get('id') is not None - ): - retained_checkpoint['id'] = existing_checkpoint['id'] - result_messages[_checkpoint_idx] = retained_checkpoint - else: - _mark_active_turn_checkpoint(existing_checkpoint, identity) + _mark_active_turn_checkpoint(existing_checkpoint, identity) + checkpoint = identity.get('checkpoint') + if isinstance(checkpoint, dict): + for key in ('id', 'timestamp'): + if existing_checkpoint.get(key) is None and checkpoint.get(key) is not None: + existing_checkpoint[key] = copy.deepcopy(checkpoint[key]) + if checkpoint.get('attachments'): + existing_checkpoint['attachments'] = copy.deepcopy(checkpoint['attachments']) + stamp_message_source( + existing_checkpoint, + identity.get('source') or source or 'webui', + active_turn_token=identity.get('token'), + ) return result_messages previous_context = list(previous_context or []) if _messages_have_prefix(result_messages, previous_context): @@ -2402,6 +2457,45 @@ def _settle_result_messages( source=source, verification_nudge_provenance=verification_nudge_provenance, ) + trusted_agent_input = ( + active_turn_identity.get('trusted_agent_input_text') + if isinstance(active_turn_identity, dict) + else None + ) + if ( + isinstance(trusted_agent_input, str) + and active_turn_identity.get('text') == msg_text + and active_turn_identity.get('token') + ): + current_display_rows = [ + message for message in session.messages + if _active_turn_token_matches(message, active_turn_identity) + ] + current_context_rows = [ + message for message in session.context_messages + if _active_turn_token_matches(message, active_turn_identity) + ] + if ( + len(current_display_rows) == 1 + and current_display_rows[0].get('content') == msg_text + and len(current_context_rows) == 1 + ): + context_user = current_context_rows[0] + context_content = context_user.get('content') + if ( + isinstance(context_content, list) + and any( + isinstance(part, dict) + and str(part.get('type') or '').lower() + in {'image', 'image_url', 'input_image'} + for part in context_content + ) + and _active_turn_user_text_matches( + context_user, + trusted_agent_input, + ) + ): + context_user[_WEBUI_TRUSTED_AGENT_INPUT_FIELD] = trusted_agent_input _annotate_media_snapshots_for_settled_messages(session.messages) _compact_session_image_parts_for_persistence(session) _advance_truncation_watermark_after_commit(session) # #3831 @@ -2429,6 +2523,61 @@ def _current_turn_already_has_visible_assistant_answer(messages, *, active_turn_ return False +def _trusted_native_image_context_display_mirror_tokens(previous_display, previous_context): + """Find unique token-paired rich image rows already represented in display.""" + display_by_token = {} + context_by_token = {} + for messages, rows_by_token in ( + (previous_display, display_by_token), + (previous_context, context_by_token), + ): + for message in messages or []: + if not isinstance(message, dict) or message.get('role') != 'user': + continue + token = message.get('_active_turn_token') + if not isinstance(token, str) or not token: + continue + rows_by_token.setdefault(token, []).append(message) + + mirrored_tokens = set() + for token, context_rows in context_by_token.items(): + display_rows = display_by_token.get(token, []) + if len(context_rows) != 1 or len(display_rows) != 1: + continue + context, display = context_rows[0], display_rows[0] + content = context.get('content') + if ( + not isinstance(content, list) + or not any( + isinstance(part, dict) + and str(part.get('type') or '').lower() in {'image', 'image_url', 'input_image'} + for part in content + ) + or not isinstance( + context.get(_WEBUI_TRUSTED_AGENT_INPUT_FIELD), + str, + ) + or not _active_turn_user_text_matches( + context, + context.get(_WEBUI_TRUSTED_AGENT_INPUT_FIELD), + ) + or not _message_private_identity_compatible(display, context) + ): + continue + display_ts, display_ts_valid = _message_exact_timestamp_details(display) + context_ts, context_ts_valid = _message_exact_timestamp_details(context) + if ( + not display_ts_valid + or not context_ts_valid + or display_ts is None + or context_ts is None + or display_ts != context_ts + ): + continue + mirrored_tokens.add(token) + return mirrored_tokens + + def _agent_result_tool_limit_reached(result) -> bool: """Return True when current-turn metadata says the tool iteration cap fired.""" if not isinstance(result, dict): @@ -7400,6 +7549,46 @@ def _merge_display_messages_after_agent_result( result_messages = _drop_synthetic_control_messages(result_messages) if not result_messages: return previous_display + active_turn_row_index = _find_active_turn_checkpoint_index( + result_messages, previous_context, _active_turn_identity, msg_text, + ) + active_turn_row = ( + result_messages[active_turn_row_index] + if active_turn_row_index is not None + else None + ) + active_turn_display_text = None + active_content = active_turn_row.get('content') if isinstance(active_turn_row, dict) else None + if ( + isinstance(active_turn_row, dict) + and active_turn_row.get('role') == 'user' + and isinstance(active_content, list) + and any( + isinstance(part, dict) + and isinstance(part.get('type'), str) + and part.get('type') in {'image', 'image_url', 'input_image'} + for part in active_content + ) + ): + active_turn_display_text = ( + _active_turn_identity.get('text') + if _active_turn_identity.get('text') is not None + else msg_text + ) + if not isinstance(active_turn_display_text, str): + active_turn_display_text = None + else: + for display_message in previous_display: + if _active_turn_token_matches(display_message, _active_turn_identity): + display_message['content'] = active_turn_display_text + display_message.pop('api_content', None) + for key in ('id', 'timestamp', '_row_id'): + if display_message.get(key) is None and active_turn_row.get(key) is not None: + display_message[key] = copy.deepcopy(active_turn_row[key]) + if not display_message.get('attachments') and active_turn_row.get('attachments'): + display_message['attachments'] = copy.deepcopy( + active_turn_row['attachments'] + ) previous_user_tail = _stale_user_tail_candidate(_last_user_row(previous_context)) # ── Backfill normal turns from previous_context that are missing from @@ -7414,16 +7603,34 @@ def _merge_display_messages_after_agent_result( # at/after a cursor. Any context messages between the cursor and that # match are context-only gaps that get spliced in before the display msg. if previous_display and previous_context: + _displayed_native_image_context_tokens = ( + _trusted_native_image_context_display_mirror_tokens( + previous_display, + previous_context, + ) + ) _display_id_set = {_message_identity(m) for m in previous_display} _context_id_set = { _message_identity(m) for m in previous_context + if not ( + isinstance(m, dict) + and m.get('_active_turn_token') in _displayed_native_image_context_tokens + ) if not _is_context_compression_marker(m) and not _is_compressed_context_tool_result_summary_message(m) } _has_context_only_turns = bool(_context_id_set - _display_id_set) if _has_context_only_turns: - context_keys = [_message_identity(m) for m in previous_context] + context_keys = [ + None + if ( + isinstance(m, dict) + and m.get('_active_turn_token') in _displayed_native_image_context_tokens + ) + else _message_identity(m) + for m in previous_context + ] # Precompute display keys once; avoids repeated json.dumps calls inside # the inner any() loop (was O(D²·C) — see perf fix below). _display_keys = [_message_identity(m) for m in previous_display] @@ -7623,10 +7830,55 @@ def _merge_display_messages_after_agent_result( or _is_compressed_context_tool_result_summary_message(msg) ): continue + is_active_image_row = ( + active_turn_display_text is not None + and isinstance(msg, dict) + and msg.get('role') == 'user' + and ( + msg is active_turn_row + or _active_turn_token_matches(msg, _active_turn_identity) + ) + ) + if is_active_image_row: + display_row = _materialize_active_turn_user( + _active_turn_identity, + active_turn_display_text, + source, + ) + display_row['content'] = active_turn_display_text + display_row.pop('api_content', None) + for key in ('id', 'timestamp', '_row_id'): + if display_row.get(key) is None and msg.get(key) is not None: + display_row[key] = copy.deepcopy(msg[key]) + if not display_row.get('attachments') and msg.get('attachments'): + display_row['attachments'] = copy.deepcopy(msg['attachments']) + _mark_active_turn_checkpoint(display_row, _active_turn_identity) + existing_idx = next( + ( + idx for idx, existing in enumerate(merged) + if _active_turn_token_matches(existing, _active_turn_identity) + ), + None, + ) + if existing_idx is not None: + existing = merged[existing_idx] + existing['content'] = active_turn_display_text + existing.pop('api_content', None) + for key in ('id', 'timestamp', '_row_id'): + if existing.get(key) is None and msg.get(key) is not None: + existing[key] = copy.deepcopy(msg[key]) + if not existing.get('attachments') and msg.get('attachments'): + existing['attachments'] = copy.deepcopy(msg['attachments']) + if not existing.get('attachments') and display_row.get('attachments'): + existing['attachments'] = copy.deepcopy(display_row['attachments']) + _mark_active_turn_checkpoint(existing, _active_turn_identity) + continue + msg = display_row key = _message_identity(msg) is_current_user_turn = _looks_like_current_user_turn(msg, msg_text) if ( - ((key is not None and key == current_user_key) or is_current_user_turn) + not is_active_image_row + and ((key is not None and key == current_user_key) or is_current_user_turn) and merged and ( _message_identity(merged[-1]) == current_user_key @@ -7666,12 +7918,14 @@ def _merge_display_messages_after_agent_result( continue display_msg = msg if ( - ((key is not None and key == current_user_key) or is_current_user_turn) + ((key is not None and key == current_user_key) or is_current_user_turn or is_active_image_row) and isinstance(msg, dict) and msg.get('role') == 'user' ): display_msg = copy.deepcopy(msg) - display_msg['content'] = msg_text + display_msg['content'] = ( + active_turn_display_text if is_active_image_row else msg_text + ) stamp_message_source(display_msg, source) merged.append(copy.deepcopy(display_msg)) if key is not None: @@ -11520,6 +11774,11 @@ def _run_agent_streaming( profile=(getattr(s, "profile", None) or Path(_profile_home)), ) _run_conversation_kwargs["user_message"] = user_message + # This exact WebUI-composed input is identity evidence for the + # Agent-indexed current row. Keep identity['text'] as the clean + # submitted prompt for display. Assignment follows notification + # rejection/rebuild so rejected text cannot claim a returned row. + _active_turn_identity['trusted_agent_input_text'] = _agent_msg_text _result_partial_pre_call_context = list(_previous_context_messages) if not _agent_can_invoke(agent): with _agent_lock: @@ -12414,13 +12673,9 @@ def _run_agent_streaming( if attachments: display_attachments = [_attachment_name(a) for a in attachments if _attachment_name(a)] for m in reversed(s.messages): - if m.get('role') == 'user': - content = str(m.get('content', '')) - # Match if content is part of the sent message or vice-versa - base_text = msg_text.split('\n\n[Attached files:')[0].strip() if '\n\n[Attached files:' in msg_text else msg_text - if base_text[:60] in content or content[:60] in msg_text: - m['attachments'] = display_attachments - break + if _active_turn_token_matches(m, _active_turn_identity): + m['attachments'] = display_attachments + break # Persist reasoning trace in the session so it survives reload. # Must run BEFORE s.save() — otherwise the mutation lives only in # memory until the next turn's save, and the last-turn thinking card diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py new file mode 100644 index 00000000..18863c4e --- /dev/null +++ b/tests/test_native_image_turn_display_context.py @@ -0,0 +1,801 @@ +"""Native-image turns keep model context private from the visible transcript.""" + +import json +import sqlite3 +from types import SimpleNamespace + +from api.helpers import public_session_projection +from api.models import get_state_db_session_messages +from api.process_event_utils import build_active_turn_token +from api.streaming import ( + _materialize_active_turn_user, + _new_turn_context_from_messages, + _active_turn_authority, + _find_active_turn_checkpoint_index, + _sanitize_messages_for_agent, + _settle_result_messages, +) + + +IMAGE_A = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC" +IMAGE_B = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNg+M8AAAICAQB7CYF4AAAAAElFTkSuQmCC" +RECALL_NOTE = "Recall note: the sample is neutral." +PLUGIN_NOTE = "Pre-call note: summarize visible details only." + + +def _native_user_content(text, image_url, extra_text=()): + parts = [{"type": "text", "text": f"[Workspace::v1: /fixture]\n{text}"}] + if image_url: + parts.append({"type": "image_url", "image_url": {"url": image_url}}) + parts.extend({"type": "text", "text": value} for value in extra_text) + return parts + + +def _durable_agent_content(content): + parts = [] + for part in content: + parts.append("[screenshot]" if part.get("type") == "image_url" else part["text"]) + return " ".join("\n".join(parts).split()) + + +def _settle_image_turn( + *, + session_id="native-image-session", + text="Describe this image", + image_url=IMAGE_A, + timestamp=100.0, + extra_text=(), + queued_notifications=(), + agent_row_id=None, + empty_display_before_settle=False, + previous_messages=(), + previous_context=(), + attachment_name="sample.png", +): + stream_id = f"native-image-stream-{timestamp}" + token = build_active_turn_token(stream_id, timestamp) + attachments = ( + [{"name": attachment_name, "mime": "image/png", "is_image": True}] + if attachment_name + else [] + ) + checkpoint = ({ + "role": "user", + "content": text, + "timestamp": timestamp, + "attachments": attachments, + "_active_turn_token": token, + } if text else None) + session = SimpleNamespace( + session_id=session_id, + messages=[*previous_messages, *([checkpoint] if checkpoint else [])], + context_messages=list(previous_context), + pending_user_message=text, + pending_attachments=attachments, + pending_started_at=timestamp, + pending_user_source="webui", + ) + identity = _active_turn_authority(session, stream_id, text) + agent_input_text = ( + "\n\n".join([*queued_notifications, text]).strip() + if queued_notifications + else text + ) + identity["trusted_agent_input_text"] = agent_input_text + identity.update({ + "current_turn_user_idx": len(previous_context), + "turn_id": f"agent-turn-{timestamp}", + "agent_turn_boundary_resolved": True, + }) + rich_content = _native_user_content(agent_input_text, image_url, extra_text) + api_content = json.dumps(rich_content) + current_user = { + "role": "user", + "content": rich_content, + "timestamp": timestamp, + "api_content": api_content, + } + if agent_row_id is not None: + current_user["_row_id"] = agent_row_id + result_messages = [ + *previous_context, + current_user, + {"role": "assistant", "content": "A neutral sample image."}, + ] + if empty_display_before_settle: + session.messages = [] + assert not any( + message.get("_active_turn_token") == identity["token"] + for message in session.messages + ) + _settle_result_messages( + session, + session.messages, + previous_context, + result_messages, + text, + "webui", + identity, + ) + return session, identity, api_content + + +def test_trusted_notification_prefix_keeps_image_turn_clean_after_reload( + monkeypatch, tmp_path +): + import api.models as models + + text = "Describe this image" + notification = "Queued process update: sample job completed." + session, identity, _ = _settle_image_turn( + text=text, + queued_notifications=(notification,), + extra_text=(RECALL_NOTE, PLUGIN_NOTE), + timestamp=900.0, + ) + expected_context = _native_user_content( + f"{notification}\n\n{text}", IMAGE_A, (RECALL_NOTE, PLUGIN_NOTE) + ) + display_users = [ + message for message in session.messages if message.get("role") == "user" + ] + assert len(display_users) == 1 + assert display_users[0]["content"] == text + assert display_users[0]["attachments"] == [ + {"name": "sample.png", "mime": "image/png", "is_image": True} + ] + context_users = [ + message for message in session.context_messages + if message.get("role") == "user" + ] + assert len(context_users) == 1 + assert context_users[0]["content"] == expected_context + + session_dir = tmp_path / "webui-sessions" + session_dir.mkdir() + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + models.Session( + session_id=session.session_id, + workspace="/fixture", + model="fixture-model", + context_length=128_000, + messages=session.messages, + context_messages=session.context_messages, + ).save(skip_index=True) + reloaded = models.Session.load(session.session_id) + assert reloaded is not None + reloaded_users = [ + message for message in reloaded.messages if message.get("role") == "user" + ] + assert len(reloaded_users) == 1 + assert reloaded_users[0]["content"] == text + assert reloaded_users[0]["attachments"] == display_users[0]["attachments"] + reloaded_context_users = [ + message for message in reloaded.context_messages + if message.get("role") == "user" + ] + assert len(reloaded_context_users) == 1 + assert reloaded_context_users[0]["content"] == expected_context + + identity["current_turn_user_idx"] = 0 + untrusted = [{ + "role": "user", + "content": _native_user_content( + f"Untrusted text prefix\n\n{text}", IMAGE_A, (RECALL_NOTE, PLUGIN_NOTE) + ), + }] + assert _find_active_turn_checkpoint_index( + untrusted, [], identity, text, + ) is None + + +def _write_state_db(path, session_id, rows): + with sqlite3.connect(path) as conn: + conn.execute( + "CREATE TABLE messages (" + "id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, " + "timestamp REAL, active INTEGER DEFAULT 1, api_content TEXT)" + ) + conn.executemany( + "INSERT INTO messages (session_id, role, content, timestamp, api_content) " + "VALUES (?, ?, ?, ?, ?)", + [ + (session_id, "user", content, timestamp, api_content) + for content, timestamp, api_content in rows + ], + ) + + +def test_settlement_reload_and_next_turn_keep_one_clean_bubble_and_rich_context( + monkeypatch, tmp_path +): + import api.config + import api.models as models + from api import routes + + text = "Describe this image" + notification = "Queued process update: sample job completed." + session, identity, api_content = _settle_image_turn( + text=text, + queued_notifications=(notification,), + extra_text=(RECALL_NOTE, PLUGIN_NOTE), + agent_row_id=1, + empty_display_before_settle=True, + ) + token = identity["token"] + session_dir = tmp_path / "webui-sessions" + session_dir.mkdir() + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + persisted = models.Session( + session_id=session.session_id, + workspace="/fixture", + model="fixture-model", + context_length=128_000, + messages=session.messages, + context_messages=session.context_messages, + ) + persisted.save(skip_index=True) + session = models.Session.load(session.session_id) + assert session is not None + + current_rows = [ + message for message in session.messages + if isinstance(message, dict) and message.get("_active_turn_token") == token + ] + assert len(current_rows) == 1 + assert current_rows[0]["content"] == text + assert "_webui_display_content" not in current_rows[0] + assert "api_content" not in current_rows[0] + assert current_rows[0]["attachments"][0]["name"] == "sample.png" + assert current_rows[0]["_row_id"] == 1 + settled_current = [ + message for message in session.messages + if message.get("role") == "user" and message.get("timestamp") == 100.0 + ] + assert len(settled_current) == 1 + assert settled_current[0]["content"] == text + + context_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == token + ) + expected_context = _native_user_content( + f"{notification}\n\n{text}", IMAGE_A, (RECALL_NOTE, PLUGIN_NOTE) + ) + assert context_user["content"] == expected_context + assert context_user["api_content"] == api_content + assert context_user["_row_id"] == 1 + assert context_user["_webui_trusted_agent_input_text"] == f"{notification}\n\n{text}" + assert "_webui_trusted_agent_input_text" not in current_rows[0] + + db_path = tmp_path / "state.db" + _write_state_db( + db_path, + session.session_id, + [(_durable_agent_content(context_user["content"]), 100.0, None)], + ) + monkeypatch.setattr(models, "_active_state_db_path", lambda: db_path) + state_rows = get_state_db_session_messages(session.session_id) + assert len(state_rows) == 1 + assert state_rows[0].get("api_content") is None + assert state_rows[0]["_state_db_row_id"] == 1 + display_rows = models.reconciled_state_db_messages_for_session( + session, + state_messages=state_rows, + ) + current_display_rows = [ + message for message in display_rows + if message.get("_active_turn_token") == token + ] + assert len(current_display_rows) == 1 + reconciled_current = [ + message for message in display_rows + if message.get("role") == "user" and message.get("timestamp") == 100.0 + ] + assert len(reconciled_current) == 1 + assert reconciled_current[0]["content"] == text + assert reconciled_current[0]["attachments"][0]["name"] == "sample.png" + + monkeypatch.setattr(api.config, "load_settings", lambda: {"api_redact_enabled": False}) + public = public_session_projection({ + "session_id": session.session_id, + "messages": display_rows, + }) + public_current = [ + message for message in public["messages"] + if message.get("role") == "user" and message.get("timestamp") == 100.0 + ] + assert len(public_current) == 1 + assert public_current[0]["content"] == text + assert public_current[0]["attachments"][0]["name"] == "sample.png" + assert not any( + key in public_current[0] + for key in ("_webui_display_content", "_active_turn_token", "api_content") + ) + + route_response = {} + monkeypatch.setattr( + routes, + "get_session", + lambda sid, metadata_only=False: models.Session.load(sid), + ) + monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *_args: True) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: set()) + monkeypatch.setattr( + routes, + "j", + lambda _handler, payload, status=200, **_kwargs: ( + route_response.update(payload=payload, status=status) or payload + ), + ) + routes._handle_session_get( + None, + SimpleNamespace( + path="/api/session", + query=f"session_id={session.session_id}&resolve_model=0", + ), + ) + assert route_response["status"] == 200 + route_messages = route_response["payload"]["session"]["messages"] + route_current = [ + message for message in route_messages + if message.get("role") == "user" and message.get("timestamp") == 100.0 + ] + assert len(route_current) == 1 + assert route_current[0]["content"] == text + assert route_current[0]["attachments"][0]["name"] == "sample.png" + assert not any( + key in route_current[0] + for key in ("_webui_display_content", "_active_turn_token", "api_content") + ) + projected_context = public_session_projection({ + "context_messages": session.context_messages, + })["context_messages"] + assert not any( + "_webui_trusted_agent_input_text" in message + for message in projected_context + ) + + recovered_context = models.reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=state_rows, + ) + replay = _sanitize_messages_for_agent(recovered_context) + replay_users = [message for message in replay if message.get("role") == "user"] + assert len(replay_users) == 1 + assert replay_users[0]["content"] == expected_context + assert replay_users[0]["api_content"] == api_content + + second_text = "What object is shown?" + next_turn_history = _new_turn_context_from_messages( + recovered_context, + second_text, + ) + model_history = _sanitize_messages_for_agent(next_turn_history) + model_history_user = next( + message for message in model_history if message.get("role") == "user" + ) + assert model_history_user["content"] == expected_context + assert model_history_user["api_content"] == api_content + + second_stream_id = "native-image-second-stream" + session.active_stream_id = second_stream_id + session.pending_user_message = second_text + session.pending_attachments = [] + session.pending_started_at = 200.0 + session.pending_user_source = "webui" + second_identity = _active_turn_authority(session, second_stream_id, second_text) + second_identity.update({ + "current_turn_user_idx": len(model_history), + "turn_id": "agent-turn-second", + "agent_turn_boundary_resolved": True, + }) + checkpoint = _materialize_active_turn_user(second_identity, second_text, "webui") + second_identity["checkpoint"] = checkpoint + previous_display = [*display_rows, checkpoint] + session.messages = previous_display + result_messages = [ + *model_history, + { + "role": "user", + "content": f"[Workspace::v1: /fixture]\n{second_text}", + "timestamp": 200.0, + }, + {"role": "assistant", "content": "A neutral follow-up.", "timestamp": 200.1}, + ] + state_rows_before_second_turn = list(state_rows) + _settle_result_messages( + session, + previous_display, + recovered_context, + result_messages, + second_text, + "webui", + second_identity, + ) + + settled_users = [ + message for message in session.messages if message.get("role") == "user" + ] + assert [message["content"] for message in settled_users] == [text, second_text] + assert settled_users[0]["attachments"][0]["name"] == "sample.png" + rich_after_second_turn = next( + message for message in session.context_messages + if message.get("_active_turn_token") == token + ) + assert rich_after_second_turn["content"] == expected_context + assert rich_after_second_turn["_webui_trusted_agent_input_text"] == ( + f"{notification}\n\n{text}" + ) + assert get_state_db_session_messages(session.session_id) == state_rows_before_second_turn + + session.active_stream_id = None + session.pending_user_message = None + session.pending_attachments = [] + session.pending_started_at = None + session.pending_user_source = None + session.save(skip_index=True) + session = models.Session.load(session.session_id) + assert session is not None + reloaded_users = [ + message for message in session.messages if message.get("role") == "user" + ] + assert [message["content"] for message in reloaded_users] == [text, second_text] + assert reloaded_users[0]["attachments"][0]["name"] == "sample.png" + reloaded_rich_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == token + ) + assert reloaded_rich_user["content"] == expected_context + assert reloaded_rich_user["_webui_trusted_agent_input_text"] == ( + f"{notification}\n\n{text}" + ) + + route_response.clear() + routes._handle_session_get( + None, + SimpleNamespace( + path="/api/session", + query=f"session_id={session.session_id}&resolve_model=0", + ), + ) + api_users = [ + message for message in route_response["payload"]["session"]["messages"] + if message.get("role") == "user" + ] + assert [message["content"] for message in api_users] == [text, second_text] + assert api_users[0]["attachments"][0]["name"] == "sample.png" + + +def test_image_only_and_no_context_turns_keep_the_submitted_display_text(): + image_only, image_only_identity, _ = _settle_image_turn( + text="", + image_url=IMAGE_A, + timestamp=200.0, + attachment_name="image-only.png", + ) + image_only_row = next( + message for message in image_only.messages + if message.get("_active_turn_token") == image_only_identity["token"] + ) + assert image_only_row["content"] == "" + assert "_webui_display_content" not in image_only_row + assert "api_content" not in image_only_row + assert image_only_row["attachments"][0]["name"] == "image-only.png" + + no_context, no_context_identity, _ = _settle_image_turn( + text="What is in this image?", + image_url=IMAGE_B, + timestamp=300.0, + ) + no_context_row = next( + message for message in no_context.messages + if message.get("_active_turn_token") == no_context_identity["token"] + ) + assert no_context_row["content"] == "What is in this image?" + assert "_webui_display_content" not in no_context_row + + no_prefetch, no_prefetch_identity, _ = _settle_image_turn( + text="This image was not prefetched.", + image_url=IMAGE_A, + timestamp=350.0, + attachment_name=None, + ) + no_prefetch_row = next( + message for message in no_prefetch.messages + if message.get("_active_turn_token") == no_prefetch_identity["token"] + ) + assert no_prefetch_row["content"] == "This image was not prefetched." + assert no_prefetch_row.get("attachments") in (None, []) + + +def test_repeated_literal_marker_prompt_keeps_distinct_image_turns(monkeypatch): + import api.config + import api.models as models + + monkeypatch.setattr(api.config, "load_settings", lambda: {"api_redact_enabled": False}) + prompt = "Please keep this literal text." + first, first_identity, _ = _settle_image_turn( + text=prompt, + image_url=IMAGE_A, + timestamp=400.0, + attachment_name="first.png", + ) + second, second_identity, _ = _settle_image_turn( + text=prompt, + image_url=IMAGE_B, + timestamp=500.0, + previous_messages=first.messages, + previous_context=first.context_messages, + attachment_name="second.png", + ) + tokens = {first_identity["token"], second_identity["token"]} + for row_id, token in enumerate((first_identity["token"], second_identity["token"]), 1): + for rows in (second.messages, second.context_messages): + next(message for message in rows if message.get("_active_turn_token") == token)[ + "_row_id" + ] = row_id + settled_turns = [ + message for message in second.messages + if message.get("role") == "user" and message.get("timestamp") in (400.0, 500.0) + ] + assert len(settled_turns) == 2 + assert [message["content"] for message in settled_turns] == [prompt, prompt] + assert {message.get("_active_turn_token") for message in settled_turns} == tokens + assert [message["attachments"][0]["name"] for message in settled_turns] == [ + "first.png", "second.png", + ] + + state_rows = [] + for message in second.context_messages: + if message.get("_active_turn_token") in tokens: + state_rows.append({ + "role": "user", + "content": _durable_agent_content(message["content"]), + "timestamp": message["timestamp"], + "api_content": message["api_content"], + "_state_db_row_id": len(state_rows) + 1, + }) + display_rows = models.reconciled_state_db_messages_for_session( + second, + state_messages=state_rows, + ) + displayed_turns = [ + message for message in display_rows + if message.get("role") == "user" and message.get("timestamp") in (400.0, 500.0) + ] + assert len(displayed_turns) == 2 + assert [message["content"] for message in displayed_turns] == [prompt, prompt] + assert [message["attachments"][0]["name"] for message in displayed_turns] == [ + "first.png", "second.png", + ] + + public = public_session_projection({ + "session_id": second.session_id, + "messages": display_rows, + }) + public_turns = [ + message for message in public["messages"] + if message.get("role") == "user" and message.get("timestamp") in (400.0, 500.0) + ] + assert len(public_turns) == 2 + assert [message["content"] for message in public_turns] == [prompt, prompt] + assert [message["attachments"][0]["name"] for message in public_turns] == [ + "first.png", "second.png", + ] + + +def test_state_db_mirror_with_conflicting_identity_is_not_suppressed(): + import api.models as models + + session, identity, api_content = _settle_image_turn( + text="Describe this image", + extra_text=(RECALL_NOTE,), + timestamp=600.0, + ) + context_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + conflicting = { + "role": "user", + "content": _durable_agent_content(context_user["content"]), + "timestamp": 600.0, + "api_content": api_content + " ", + "_state_db_row_id": 1, + } + reconciled = models.reconciled_state_db_messages_for_session( + session, + state_messages=[conflicting], + ) + assert any(message.get("content") == conflicting["content"] for message in reconciled) + + +def test_ambiguous_state_db_mirror_identity_is_not_suppressed(): + import api.models as models + + session, identity, api_content = _settle_image_turn( + text="Describe this image", + extra_text=(RECALL_NOTE,), + timestamp=700.0, + ) + context_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + mirror = _durable_agent_content(context_user["content"]) + state_rows = [ + { + "role": "user", + "content": mirror, + "timestamp": 700.0, + "api_content": api_content, + "_state_db_row_id": row_id, + } + for row_id in (11, 12) + ] + reconciled = models.reconciled_state_db_messages_for_session( + session, + state_messages=state_rows, + ) + assert any(message.get("content") == mirror for message in reconciled) + + +def test_state_db_image_projection_suppresses_only_exact_agent_row_id( + monkeypatch, tmp_path +): + import api.models as models + + timestamp = 800.0 + session, identity, _ = _settle_image_turn( + text="Describe this image", + queued_notifications=("Queued process update: sample job completed.",), + extra_text=(RECALL_NOTE, PLUGIN_NOTE), + timestamp=timestamp, + agent_row_id=41, + ) + context_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + mirror = _durable_agent_content(context_user["content"]) + + db_path = tmp_path / "state.db" + with sqlite3.connect(db_path) as conn: + conn.execute( + "CREATE TABLE messages (" + "id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, " + "timestamp REAL, active INTEGER DEFAULT 1, api_content TEXT)" + ) + conn.executemany( + "INSERT INTO messages (id, session_id, role, content, timestamp, api_content) " + "VALUES (?, ?, ?, ?, ?, NULL)", + [ + (41, session.session_id, "user", mirror, timestamp), + (42, session.session_id, "user", mirror, timestamp), + ], + ) + monkeypatch.setattr(models, "_active_state_db_path", lambda: db_path) + state_rows = models.get_state_db_session_messages(session.session_id) + assert [message["_state_db_row_id"] for message in state_rows] == [41, 42] + assert all("api_content" not in message for message in state_rows) + + reconciled = models.reconciled_state_db_messages_for_session( + session, + state_messages=state_rows, + ) + visible_literals = [ + message for message in reconciled + if message.get("role") == "user" and message.get("content") == mirror + ] + assert len(visible_literals) == 1 + assert visible_literals[0]["_state_db_row_id"] == 42 + public_literals = [ + message for message in public_session_projection({"messages": reconciled})["messages"] + if message.get("role") == "user" and message.get("content") == mirror + ] + assert len(public_literals) == 1 + assert "_state_db_row_id" not in public_literals[0] + + for untrusted_identity in ( + {key: value for key, value in state_rows[0].items() if key != "_state_db_row_id"}, + {**state_rows[0], "_row_id": 42}, + ): + unresolved = models.merge_session_messages_append_only( + session.messages, + models._suppress_native_image_display_mirrors( + session, + [untrusted_identity], + ), + incoming_provenance="state_db", + ) + assert any(message.get("content") == mirror for message in unresolved) + assert not any( + "_webui_unmatched_native_image_mirror" in message + for message in unresolved + ) + + +def test_unlinked_state_db_image_projection_uses_existing_reconciliation(): + import api.models as models + + for prefer_context in (False, True): + for flushed in (False, True): + timestamp = 900.0 + session, identity, _ = _settle_image_turn( + timestamp=timestamp, + extra_text=(RECALL_NOTE, PLUGIN_NOTE), + agent_row_id=None, + ) + context_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + assert not any( + key in context_user + for key in ("_row_id", "_state_db_row_id", "_db_row_id", "state_db_row_id") + ) + state_rows = ( + [{ + "role": "user", + "content": _durable_agent_content(context_user["content"]), + "timestamp": timestamp, + "_state_db_row_id": 1, + }] + if flushed + else [] + ) + + recovered = models.reconciled_state_db_messages_for_session( + session, + prefer_context=prefer_context, + state_messages=state_rows, + ) + users = [ + message for message in recovered + if message.get("role") == "user" and message.get("timestamp") == timestamp + ] + assert len(users) == 1 + display_users = [ + message for message in session.messages + if message.get("role") == "user" and message.get("timestamp") == timestamp + ] + assert len(display_users) == 1 + assert display_users[0]["content"] == "Describe this image" + assert users[0]["content"] == ( + context_user["content"] if prefer_context else "Describe this image" + ) + if prefer_context: + next_turn = _new_turn_context_from_messages( + recovered, + "Tell me more", + ) + replay_users = [message for message in next_turn if message.get("role") == "user"] + assert len(replay_users) == 1 + assert replay_users[0]["content"] == context_user["content"] + + +def test_agent_index_and_turn_id_do_not_claim_unrelated_user_row(): + _, identity, _ = _settle_image_turn(text="Describe this image") + unrelated = [{"role": "user", "content": "Different submitted text"}] + identity["current_turn_user_idx"] = 0 + assert _find_active_turn_checkpoint_index( + unrelated, + [], + identity, + "Describe this image", + ) is None + + matching = [{ + "role": "user", + "content": _native_user_content( + "Describe this image", IMAGE_A, (RECALL_NOTE, PLUGIN_NOTE) + ), + }] + assert _find_active_turn_checkpoint_index( + matching, + [], + identity, + "Describe this image", + ) == 0 From 1f13717ca90b4c2819db39617201dd8952c3328a Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:47:35 -0600 Subject: [PATCH 02/16] fix(chat): preserve plain-turn matching during settlement Keep whitespace-normalized text matches for plain Agent rows, retain float timestamps for Gateway turn checkpoints, and test optional attachment suffix behavior rather than source spelling. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna --- api/gateway_chat.py | 14 +++++++++++++- api/streaming.py | 5 +++++ tests/test_batch_fixes.py | 13 +++++++------ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/api/gateway_chat.py b/api/gateway_chat.py index e155329f..23833777 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -1297,7 +1297,11 @@ def _run_gateway_chat_streaming( # role/content ordering instead of turn order. assistant_ts = now + 0.000001 pending_source = getattr(s, "pending_user_source", None) or "webui" - from api.streaming import _active_turn_authority, _materialize_active_turn_user + from api.streaming import ( + _active_turn_authority, + _active_turn_token_matches, + _materialize_active_turn_user, + ) active_turn_identity = _active_turn_authority(s, stream_id, msg_text) user_msg = _materialize_active_turn_user( @@ -1373,6 +1377,14 @@ def _run_gateway_chat_streaming( if latest_text == msg_norm: display = display[:-1] s.messages = display + [user_msg, assistant_msg] + if active_turn_identity.get("token"): + current_display_rows = [ + message + for message in s.messages + if _active_turn_token_matches(message, active_turn_identity) + ] + if len(current_display_rows) == 1: + current_display_rows[0]["timestamp"] = user_msg["timestamp"] s.active_stream_id = None s.pending_user_message = None s.pending_attachments = None diff --git a/api/streaming.py b/api/streaming.py index b224dbe2..2804150a 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2105,6 +2105,11 @@ def _active_turn_user_text_matches(message, expected_text): actual_text = content else: return False + if ( + isinstance(content, str) + and " ".join(actual_text.split()) == " ".join(expected_text.split()) + ): + return True return _submitted_user_text_matches(actual_text, expected_text) diff --git a/tests/test_batch_fixes.py b/tests/test_batch_fixes.py index e4a5a138..853979c8 100644 --- a/tests/test_batch_fixes.py +++ b/tests/test_batch_fixes.py @@ -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 ───────────────────────────────────── From bf0646f64986d0b826280dbaf0254a36114f531e Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:12:11 -0600 Subject: [PATCH 03/16] fix(chat): avoid reinserting recovered image rows Reuse the existing unique state-row identity guard before preserving a distinct image projection, so repeated reconciliation does not duplicate the same row. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna --- api/models.py | 22 +++++++++++ .../test_native_image_turn_display_context.py | 37 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/api/models.py b/api/models.py index 056cd1c8..972312ee 100644 --- a/api/models.py +++ b/api/models.py @@ -10993,6 +10993,28 @@ def _merge_session_messages_append_only_impl( visible_key = _cached_message_key(msg, "visible_state") content_key = _cached_message_key(msg, "content_state") if preserve_native_image_row: + row_id, row_id_valid = _state_db_row_identity_details(msg) + existing = ( + merged_by_row_id.get(row_id) + if row_id_valid and row_id is not None + else None + ) + if ( + existing is not None + and row_id not in ambiguous_row_ids + and _row_id_fast_path_allowed(existing, msg) + ): + existing_api_content = _session_message_api_content_key(existing) + incoming_api_content = _session_message_api_content_key(msg) + if not ( + existing_api_content is not None + and incoming_api_content is not None + and existing_api_content != incoming_api_content + ): + if existing_api_content is None and incoming_api_content is not None: + _copy_api_content_sidecar(existing, msg) + _merge_session_display_metadata(existing, msg) + continue if not _insert_state_message_chronologically(merged_messages, msg): merged_messages.append(msg) seen_message_keys.add(key) diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index 18863c4e..2a17e2f7 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -698,6 +698,43 @@ def test_state_db_image_projection_suppresses_only_exact_agent_row_id( assert len(public_literals) == 1 assert "_state_db_row_id" not in public_literals[0] + reconciled_context = models.reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=state_rows, + ) + session.messages = reconciled + session.context_messages = reconciled_context + first_api_transcript = public_session_projection( + {"messages": session.messages} + )["messages"] + first_next_replay = _new_turn_context_from_messages( + session.context_messages, + "Tell me more", + ) + + reconciled_again = models.reconciled_state_db_messages_for_session( + session, + state_messages=state_rows, + ) + reconciled_context_again = models.reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=state_rows, + ) + for messages in (reconciled_again, reconciled_context_again): + assert sum( + message.get("_state_db_row_id") == 42 + for message in messages + ) == 1 + assert public_session_projection( + {"messages": reconciled_again} + )["messages"] == first_api_transcript + assert _new_turn_context_from_messages( + reconciled_context_again, + "Tell me more", + ) == first_next_replay + for untrusted_identity in ( {key: value for key, value in state_rows[0].items() if key != "_state_db_row_id"}, {**state_rows[0], "_row_id": 42}, From 304390997339d1ad619cfb5c9363d51e4b7b405e Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:23:39 -0600 Subject: [PATCH 04/16] fix(chat): recover valid provider sidecars from malformed rows Treat a malformed provider sidecar as absent when copying a uniquely matched durable payload; retain valid existing payloads unchanged. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna --- api/models.py | 5 ++- .../test_native_image_turn_display_context.py | 45 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/api/models.py b/api/models.py index 972312ee..3216d3fa 100644 --- a/api/models.py +++ b/api/models.py @@ -9687,7 +9687,7 @@ def _visible_content_compatible(target: dict | None, source: dict | None) -> boo def _copy_api_content_sidecar(target: dict | None, source: dict | None) -> bool: - """Copy a non-empty internal sidecar without replacing an existing one.""" + """Copy a valid sidecar unless the target already has a valid one.""" if not isinstance(target, dict) or not isinstance(source, dict): return False target_role = _message_sidecar_role(target) @@ -9706,7 +9706,8 @@ def _copy_api_content_sidecar(target: dict | None, source: dict | None) -> bool: ) ): return False - if target.get("api_content") not in (None, ""): + target_api_content = target.get("api_content") + if isinstance(target_api_content, str) and target_api_content: return True api_content = source.get("api_content") if isinstance(api_content, str) and api_content: diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index 2a17e2f7..fa6661e9 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -754,6 +754,51 @@ def test_state_db_image_projection_suppresses_only_exact_agent_row_id( ) +def test_marked_native_image_mirror_repairs_malformed_sidecar_once(): + import api.models as models + + timestamp = 850.0 + session, identity, api_content = _settle_image_turn( + timestamp=timestamp, + agent_row_id=41, + ) + context_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + mirror = _durable_agent_content(context_user["content"]) + session.messages.append({ + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 42, + "api_content": [], + }) + state_row = { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 42, + "api_content": api_content, + } + + marked = models._suppress_native_image_display_mirrors(session, [state_row]) + assert len(marked) == 1 + assert marked[0]["_webui_unmatched_native_image_mirror"] is True + + for _ in range(2): + reconciled = models.reconciled_state_db_messages_for_session( + session, + state_messages=[state_row], + ) + replay_rows = [ + message for message in _sanitize_messages_for_agent(reconciled) + if message.get("content") == mirror + ] + assert len(replay_rows) == 1 + assert replay_rows[0]["api_content"] == api_content + + def test_unlinked_state_db_image_projection_uses_existing_reconciliation(): import api.models as models From e79e6f5cda07f4ac6d34860db5e20800cc064bd2 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:30:41 -0600 Subject: [PATCH 05/16] fix(chat): bound conflicting recovered image rows Keep conflicting provider payloads distinct, while deduplicating the durable row across repeated reconciliation. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna Assisted-by: Claude Code:claude-opus-5-5 --- api/models.py | 5 ++ .../test_native_image_turn_display_context.py | 70 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/api/models.py b/api/models.py index 3216d3fa..51a70268 100644 --- a/api/models.py +++ b/api/models.py @@ -11016,6 +11016,11 @@ def _merge_session_messages_append_only_impl( _copy_api_content_sidecar(existing, msg) _merge_session_display_metadata(existing, msg) continue + if dedup_key in seen_dedup_keys: + _merge_session_display_metadata( + merged_by_dedup_key.get(dedup_key), msg + ) + continue if not _insert_state_message_chronologically(merged_messages, msg): merged_messages.append(msg) seen_message_keys.add(key) diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index fa6661e9..6e5390b0 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -799,6 +799,76 @@ def test_marked_native_image_mirror_repairs_malformed_sidecar_once(): assert replay_rows[0]["api_content"] == api_content +def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery(): + import api.models as models + + timestamp = 860.0 + session, identity, _ = _settle_image_turn( + timestamp=timestamp, + agent_row_id=41, + ) + context_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + mirror = _durable_agent_content(context_user["content"]) + session.messages.append({ + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 42, + "api_content": "OLD-PROVIDER-BYTES", + }) + state_row = { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 42, + "api_content": "NEW-PROVIDER-BYTES", + } + marked = models._suppress_native_image_display_mirrors(session, [state_row]) + assert marked[0]["_webui_unmatched_native_image_mirror"] is True + + first_public = None + first_replay = None + for _ in range(4): + display = models.reconciled_state_db_messages_for_session( + session, + state_messages=[state_row], + ) + context = models.reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=[state_row], + ) + display_row_42 = [ + message for message in display + if message.get("_state_db_row_id") == 42 + ] + context_row_42 = [ + message for message in context + if message.get("_state_db_row_id") == 42 + ] + assert len(display_row_42) == 2 + assert {message["api_content"] for message in display_row_42} == { + "OLD-PROVIDER-BYTES", + "NEW-PROVIDER-BYTES", + } + assert len(context_row_42) == 1 + assert context_row_42[0]["api_content"] == "NEW-PROVIDER-BYTES" + + public = public_session_projection({"messages": display})["messages"] + replay = _sanitize_messages_for_agent(context) + if first_public is None: + first_public = public + first_replay = replay + else: + assert public == first_public + assert replay == first_replay + session.messages = display + session.context_messages = context + + def test_unlinked_state_db_image_projection_uses_existing_reconciliation(): import api.models as models From b5596c5acf25e58a8e592c7d2db6290e57b9d3a6 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:39:25 -0600 Subject: [PATCH 06/16] fix(chat): preserve distinct recovered image rows Match durable row identity as well as content before skipping a recovered image row, preserving separate same-text turns. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna Assisted-by: Claude Code:claude-opus-5-5 --- api/models.py | 27 ++++++++- .../test_native_image_turn_display_context.py | 57 +++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/api/models.py b/api/models.py index 51a70268..1077cde7 100644 --- a/api/models.py +++ b/api/models.py @@ -11017,10 +11017,31 @@ def _merge_session_messages_append_only_impl( _merge_session_display_metadata(existing, msg) continue if dedup_key in seen_dedup_keys: - _merge_session_display_metadata( - merged_by_dedup_key.get(dedup_key), msg + duplicate = merged_by_dedup_key.get(dedup_key) + duplicate_row_id, duplicate_row_id_valid = ( + _state_db_row_identity_details(duplicate) ) - continue + same_durable_row = ( + row_id_valid + and row_id is not None + and duplicate_row_id_valid + and duplicate_row_id == row_id + ) + if not same_durable_row and row_id_valid and row_id is not None: + duplicate = merged_by_row_id.get(row_id) + duplicate_row_id, duplicate_row_id_valid = ( + _state_db_row_identity_details(duplicate) + ) + same_durable_row = ( + row_id not in ambiguous_row_ids + and duplicate_row_id_valid + and duplicate_row_id == row_id + and _cached_message_key(duplicate, "dedup") == dedup_key + and _row_id_fast_path_allowed(duplicate, msg) + ) + if same_durable_row: + _merge_session_display_metadata(duplicate, msg) + continue if not _insert_state_message_chronologically(merged_messages, msg): merged_messages.append(msg) seen_message_keys.add(key) diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index 6e5390b0..a04b145f 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -869,6 +869,63 @@ def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery(): session.context_messages = context +def test_marked_native_image_rows_with_distinct_ids_survive_recovery(): + import api.models as models + + timestamp = 870.0 + session, identity, api_content = _settle_image_turn( + timestamp=timestamp, + agent_row_id=41, + ) + context_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + mirror = _durable_agent_content(context_user["content"]) + state_rows = [ + { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": row_id, + "api_content": api_content, + } + for row_id in (42, 43) + ] + + first_public = None + first_replay = None + for _ in range(4): + display = models.reconciled_state_db_messages_for_session( + session, + state_messages=state_rows, + ) + context = models.reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=state_rows, + ) + for messages in (display, context): + rows = [ + message for message in messages + if message.get("_state_db_row_id") in (42, 43) + ] + assert [message["_state_db_row_id"] for message in rows] == [42, 43] + assert all(message["api_content"] == api_content for message in rows) + + public = public_session_projection({"messages": display})["messages"] + replay = _sanitize_messages_for_agent(context) + if first_public is None: + first_public = public + first_replay = replay + assert sum(message.get("content") == mirror for message in public) == 2 + else: + assert public == first_public + assert replay == first_replay + session.messages = display + session.context_messages = context + + def test_unlinked_state_db_image_projection_uses_existing_reconciliation(): import api.models as models From 13552ff5679beb3c7fd26cec86f22524fae57895 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:52:04 -0600 Subject: [PATCH 07/16] fix(chat): stabilize recovery without durable row identity Use exact-key dedup by default and distinguish only rows with two valid different durable IDs. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna Assisted-by: Claude Code:claude-opus-5-5 --- api/models.py | 29 ++++--- .../test_native_image_turn_display_context.py | 81 +++++++++++++++++++ 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/api/models.py b/api/models.py index 1077cde7..2d0bd03a 100644 --- a/api/models.py +++ b/api/models.py @@ -11021,24 +11021,27 @@ def _merge_session_messages_append_only_impl( duplicate_row_id, duplicate_row_id_valid = ( _state_db_row_identity_details(duplicate) ) - same_durable_row = ( + two_distinct_durable_rows = ( row_id_valid and row_id is not None and duplicate_row_id_valid + and duplicate_row_id is not None + and duplicate_row_id != row_id + ) + if not two_distinct_durable_rows: + _merge_session_display_metadata(duplicate, msg) + continue + duplicate = merged_by_row_id.get(row_id) + duplicate_row_id, duplicate_row_id_valid = ( + _state_db_row_identity_details(duplicate) + ) + same_durable_row = ( + row_id not in ambiguous_row_ids + and duplicate_row_id_valid and duplicate_row_id == row_id + and _cached_message_key(duplicate, "dedup") == dedup_key + and _row_id_fast_path_allowed(duplicate, msg) ) - if not same_durable_row and row_id_valid and row_id is not None: - duplicate = merged_by_row_id.get(row_id) - duplicate_row_id, duplicate_row_id_valid = ( - _state_db_row_identity_details(duplicate) - ) - same_durable_row = ( - row_id not in ambiguous_row_ids - and duplicate_row_id_valid - and duplicate_row_id == row_id - and _cached_message_key(duplicate, "dedup") == dedup_key - and _row_id_fast_path_allowed(duplicate, msg) - ) if same_durable_row: _merge_session_display_metadata(duplicate, msg) continue diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index a04b145f..155e4708 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -4,6 +4,8 @@ import json import sqlite3 from types import SimpleNamespace +import pytest + from api.helpers import public_session_projection from api.models import get_state_db_session_messages from api.process_event_utils import build_active_turn_token @@ -926,6 +928,85 @@ def test_marked_native_image_rows_with_distinct_ids_survive_recovery(): session.context_messages = context +@pytest.mark.parametrize( + ("row_identity", "copies"), + [ + pytest.param({}, 1, id="idless-row"), + pytest.param({}, 2, id="duplicate-idless-rows"), + pytest.param({"_state_db_row_id": "x"}, 1, id="malformed-row-id"), + pytest.param( + {"_row_id": 42, "_state_db_row_id": 43}, + 1, + id="conflicting-row-id-aliases", + ), + ], +) +def test_untrusted_native_image_row_identity_deduplicates_stably( + row_identity, copies +): + import api.models as models + + timestamp = 880.0 + session, identity, api_content = _settle_image_turn( + timestamp=timestamp, + agent_row_id=41, + ) + context_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + mirror = _durable_agent_content(context_user["content"]) + state_rows = [ + { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "api_content": api_content, + **row_identity, + } + for _ in range(copies) + ] + + first_public = None + first_replay = None + for _ in range(4): + display = models.reconciled_state_db_messages_for_session( + session, + state_messages=state_rows, + ) + context = models.reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=state_rows, + ) + for messages in (display, context): + mirrored_rows = [ + message for message in messages + if message.get("content") == mirror + and message.get("timestamp") == timestamp + ] + assert len(mirrored_rows) == 1 + + public = public_session_projection({"messages": display})["messages"] + public_mirrors = [ + message for message in public + if message.get("content") == mirror + and message.get("timestamp") == timestamp + ] + assert len(public_mirrors) == 1 + assert "api_content" not in public_mirrors[0] + assert all("api_content" not in message for message in public) + replay = _sanitize_messages_for_agent(context) + if first_public is None: + first_public = public + first_replay = replay + else: + assert public == first_public + assert replay == first_replay + session.messages = display + session.context_messages = context + + def test_unlinked_state_db_image_projection_uses_existing_reconciliation(): import api.models as models From 5fd993cdd557cce04301b1d2c1675ff6ad788e01 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:37:15 -0600 Subject: [PATCH 08/16] fix(chat): reconcile conflicting image-row provider payloads A unique durable row remains one visible row when its sidecar provider payload is stale. Use the state.db payload for replay, cover repeated recovery, and document the image-turn ownership exception. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna --- api/models.py | 13 ++++--- .../webui-run-state-consistency-contract.md | 11 +++++- .../test_native_image_turn_display_context.py | 37 ++++++++++++++++--- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/api/models.py b/api/models.py index 2d0bd03a..10744e17 100644 --- a/api/models.py +++ b/api/models.py @@ -11007,15 +11007,18 @@ def _merge_session_messages_append_only_impl( ): existing_api_content = _session_message_api_content_key(existing) incoming_api_content = _session_message_api_content_key(msg) - if not ( + if ( existing_api_content is not None and incoming_api_content is not None and existing_api_content != incoming_api_content ): - if existing_api_content is None and incoming_api_content is not None: - _copy_api_content_sidecar(existing, msg) - _merge_session_display_metadata(existing, msg) - continue + # The unique durable row ID makes state.db authoritative + # for this marked native-image mirror's provider payload. + existing["api_content"] = incoming_api_content + elif existing_api_content is None and incoming_api_content is not None: + _copy_api_content_sidecar(existing, msg) + _merge_session_display_metadata(existing, msg) + continue if dedup_key in seen_dedup_keys: duplicate = merged_by_dedup_key.get(dedup_key) duplicate_row_id, duplicate_row_id_valid = ( diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index 7df7f1a0..5faf3808 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -171,7 +171,16 @@ and 5; it does not mark every run-state boundary implemented. do not conflict, and the pairing is unambiguous. Keep the rich sidecar row; if any requirement is missing or contradictory, preserve both rows rather than deduplicating. Literal scalar `[screenshot]` text alone is not identity - evidence. + evidence. A WebUI-submitted native-image turn has a separate display owner: + keep its exact submitted text and attachment in the visible session row, + while the Agent's expanded multipart row remains in `context_messages` for + model replay. Match its scalar state.db projection only with trusted turn + and durable-row identity, never the marker alone. For a uniquely identified + unmatched state.db row already present in the sidecar, conflicting provider + sidecars are two versions of that *one* durable row: show it once and use + the state.db provider payload for replay. Preserve genuinely distinct or + ambiguous rows. Agent state.db alone cannot restore the original attachment + if the WebUI sidecar is lost. Visible interim assistant progress must remain visible timeline content; a compact Activity disclosure may summarize adjacent tool/debug detail, but it must not be the only place where the user can see emitted progress text. diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index 155e4708..6febd954 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -805,7 +805,7 @@ def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery(): import api.models as models timestamp = 860.0 - session, identity, _ = _settle_image_turn( + session, identity, image_api_content = _settle_image_turn( timestamp=timestamp, agent_row_id=41, ) @@ -851,16 +851,41 @@ def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery(): message for message in context if message.get("_state_db_row_id") == 42 ] - assert len(display_row_42) == 2 - assert {message["api_content"] for message in display_row_42} == { - "OLD-PROVIDER-BYTES", - "NEW-PROVIDER-BYTES", - } + assert len(display_row_42) == 1 + assert display_row_42[0]["api_content"] == "NEW-PROVIDER-BYTES" assert len(context_row_42) == 1 assert context_row_42[0]["api_content"] == "NEW-PROVIDER-BYTES" public = public_session_projection({"messages": display})["messages"] + public_mirrors = [message for message in public if message.get("content") == mirror] + assert len(public_mirrors) == 1 + assert "api_content" not in public_mirrors[0] + display_image_turn = next( + message for message in display + if message.get("_active_turn_token") == identity["token"] + ) + assert display_image_turn["attachments"][0]["name"] == "sample.png" + public_image_turn = next( + message for message in public + if message.get("content") == "Describe this image" + ) + assert public_image_turn["attachments"][0]["name"] == "sample.png" + replay = _sanitize_messages_for_agent(context) + replay_row_42 = [message for message in replay if message.get("content") == mirror] + assert len(replay_row_42) == 1 + assert replay_row_42[0]["api_content"] == "NEW-PROVIDER-BYTES" + replay_image_turn = next( + message for message in replay + if isinstance(message.get("content"), list) + and any( + part.get("type") == "image_url" + and part.get("image_url", {}).get("url") == IMAGE_A + for part in message["content"] + if isinstance(part, dict) + ) + ) + assert replay_image_turn["api_content"] == image_api_content if first_public is None: first_public = public first_replay = replay From 63c8e3df31345cc7839389217daaf1ee016fcc22 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:34:30 -0600 Subject: [PATCH 09/16] fix(chat): keep pending image turns private through reload Persist trusted pending Agent timestamp evidence before the model call so session reads, including metadata polls and cold reloads, keep only the submitted prompt and attachment visible. Preserve conflicting provider payloads without guessing which copy is newer. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna --- api/models.py | 83 +++- api/routes.py | 3 +- api/streaming.py | 44 ++ .../webui-run-state-consistency-contract.md | 26 +- .../test_native_image_turn_display_context.py | 436 +++++++++++++++++- 5 files changed, 563 insertions(+), 29 deletions(-) diff --git a/api/models.py b/api/models.py index 10744e17..80098e71 100644 --- a/api/models.py +++ b/api/models.py @@ -1268,6 +1268,36 @@ def _strip_sidebar_heavy_metadata(row: dict) -> dict: return row +def _validated_webui_pending_user_timestamp_identity(session, value): + if not isinstance(value, (tuple, list)) or len(value) != 2: + return None + stream_id = getattr(session, 'active_stream_id', None) + source = str(getattr(session, 'pending_user_source', '') or '').strip().lower() + if ( + not isinstance(stream_id, str) + or not stream_id + or value[0] != stream_id + or not isinstance(getattr(session, 'pending_user_message', None), str) + or not getattr(session, 'pending_user_message', None) + or source not in {'webui', 'fork'} + ): + return None + pending_timestamp, pending_valid = _message_exact_timestamp_details( + {'timestamp': getattr(session, 'pending_started_at', None)} + ) + identity_timestamp, identity_valid = _message_exact_timestamp_details( + {'timestamp': value[1]} + ) + if ( + not pending_valid + or not identity_valid + or pending_timestamp is None + or pending_timestamp != identity_timestamp + ): + return None + return (stream_id, pending_timestamp) + + class Session: def __init__(self, session_id: str=None, title: str='Untitled', workspace=str(DEFAULT_WORKSPACE), created_workspace=None, @@ -1370,6 +1400,11 @@ class Session: self.pending_attachments = pending_attachments or [] self.pending_started_at = pending_started_at self.pending_user_source = pending_user_source + self._webui_pending_user_timestamp_identity = ( + _validated_webui_pending_user_timestamp_identity( + self, kwargs.get('_webui_pending_user_timestamp_identity') + ) + ) self.context_messages = context_messages if isinstance(context_messages, list) else [] self.compression_anchor_visible_idx = compression_anchor_visible_idx self.compression_anchor_message_key = compression_anchor_message_key @@ -1467,6 +1502,11 @@ class Session: ) if touch_updated_at: self.updated_at = time.time() + self._webui_pending_user_timestamp_identity = ( + _validated_webui_pending_user_timestamp_identity( + self, getattr(self, '_webui_pending_user_timestamp_identity', None) + ) + ) # Write metadata fields first so load_metadata_only() can read them # without parsing the full messages array (which may be 400KB+). # Fields are listed in the order they should appear in the JSON file. @@ -1477,6 +1517,7 @@ class Session: 'cache_read_tokens', 'cache_write_tokens', 'personality', 'active_stream_id', 'pending_user_message', 'pending_attachments', 'pending_started_at', 'pending_user_source', + '_webui_pending_user_timestamp_identity', 'compression_anchor_visible_idx', 'compression_anchor_message_key', 'compression_anchor_summary', 'pre_compression_snapshot', 'context_engine', 'compression_anchor_engine', 'compression_anchor_mode', @@ -9237,8 +9278,32 @@ def _suppress_native_image_display_mirrors( state_messages, *, suppress_api_content=True, + suppress_pending_turn=True, ): - """Drop exact Agent image projections backed by one token-paired WebUI turn.""" + """Drop proven pending Agent rows and native-image mirrors from display.""" + if not state_messages: + return state_messages + + if suppress_pending_turn: + identity = _validated_webui_pending_user_timestamp_identity( + session, + getattr(session, "_webui_pending_user_timestamp_identity", None), + ) + if identity is not None: + # A matching timestamp is an Agent handoff identity only because + # the live worker recorded that this exact value was passed via + # persist_user_timestamp. If rows collide on it, hide the whole + # ambiguous bucket from display; model context retains every row. + pending_timestamp = identity[1] + state_messages = [ + message for message in state_messages + if not ( + isinstance(message, dict) + and str(message.get("role") or "").lower() == "user" + and _message_exact_timestamp(message) == pending_timestamp + ) + ] + display_messages = getattr(session, "messages", None) or [] context_messages = getattr(session, "context_messages", None) or [] if not display_messages or not context_messages or not state_messages: @@ -11012,13 +11077,14 @@ def _merge_session_messages_append_only_impl( and incoming_api_content is not None and existing_api_content != incoming_api_content ): - # The unique durable row ID makes state.db authoritative - # for this marked native-image mirror's provider payload. - existing["api_content"] = incoming_api_content - elif existing_api_content is None and incoming_api_content is not None: - _copy_api_content_sidecar(existing, msg) - _merge_session_display_metadata(existing, msg) - continue + # Row identity does not establish which provider payload is + # newer. Fall through so both conflicting versions survive. + pass + else: + if existing_api_content is None and incoming_api_content is not None: + _copy_api_content_sidecar(existing, msg) + _merge_session_display_metadata(existing, msg) + continue if dedup_key in seen_dedup_keys: duplicate = merged_by_dedup_key.get(dedup_key) duplicate_row_id, duplicate_row_id_valid = ( @@ -11418,6 +11484,7 @@ def reconciled_state_db_messages_for_session( session, state_messages, suppress_api_content=not using_context_messages, + suppress_pending_turn=not prefer_context, ) if prefer_context and local_messages: if using_context_messages: diff --git a/api/routes.py b/api/routes.py index 4c8b9fc5..55a16815 100644 --- a/api/routes.py +++ b/api/routes.py @@ -13479,7 +13479,7 @@ def _handle_session_get(handler, parsed) -> bool: "tool_calls": _session_tool_calls, "active_stream_id": getattr(s, "active_stream_id", None), "pending_user_message": getattr(s, "pending_user_message", None), - "pending_attachments": getattr(s, "pending_attachments", []) if load_messages else [], + "pending_attachments": getattr(s, "pending_attachments", []) if (load_messages or getattr(s, "pending_user_message", None)) else [], "pending_started_at": getattr(s, "pending_started_at", None), "pending_user_source": getattr(s, "pending_user_source", None), "context_length": _persisted_cl, @@ -22984,6 +22984,7 @@ def _prepare_chat_start_session_for_stream( s.pending_attachments = attachments s.pending_started_at = started_at if started_at is not None else time.time() s.pending_user_source = effective_source + s._webui_pending_user_timestamp_identity = None if retained_user is not None: from api.process_event_utils import build_active_turn_token diff --git a/api/streaming.py b/api/streaming.py index 2804150a..f4df3f2f 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -79,6 +79,7 @@ from api.models import ( _is_empty_partial_activity_message, _message_exact_timestamp_details, _message_private_identity_compatible, + _validated_webui_pending_user_timestamp_identity, _evict_sessions_over_cap, clear_process_wakeup_pause, get_state_db_session_messages, @@ -9139,6 +9140,7 @@ def _add_supported_run_conversation_kwarg(callable_obj, kwargs, name, value): def _build_run_conversation_kwargs( callable_obj, *, + session=None, user_message, system_message, conversation_history, @@ -9172,6 +9174,45 @@ def _build_run_conversation_kwargs( "conversation_history_revision", conversation_history_revision, ) + if session is not None: + try: + timestamp_parameter = inspect.signature(callable_obj).parameters.get( + "persist_user_timestamp" + ) + except (TypeError, ValueError): + timestamp_parameter = None + has_timestamp_contract = ( + timestamp_parameter is not None + and timestamp_parameter.kind is not inspect.Parameter.POSITIONAL_ONLY + ) + session_id = getattr(session, "session_id", None) + save = getattr(session, "save", None) + lock = ( + _get_session_agent_lock(session_id) + if callable(save) and session_id + else contextlib.nullcontext() + ) + with lock: + candidate = ( + _validated_webui_pending_user_timestamp_identity( + session, + ( + getattr(session, "active_stream_id", None), + kwargs.get("persist_user_timestamp"), + ), + ) + if has_timestamp_contract + else None + ) + # A retry using an older/opaque Agent must revoke earlier proof; + # row identity cannot be carried across a changed invocation contract. + identity = candidate + previous = getattr(session, "_webui_pending_user_timestamp_identity", None) + session._webui_pending_user_timestamp_identity = identity + if callable(save) and session_id and previous != identity: + # Persist the proof while holding the session mutation lock, + # before run_conversation can write this turn to state.db. + save(touch_updated_at=False, skip_index=True) return kwargs @@ -11724,6 +11765,7 @@ def _run_agent_streaming( _persistent_state_before = _persistent_state_snapshot(_profile_home) _run_conversation_kwargs = _build_run_conversation_kwargs( agent.run_conversation, + session=s, user_message=user_message, system_message=workspace_system_msg, conversation_history=_sanitize_messages_for_agent( @@ -12303,6 +12345,7 @@ def _run_agent_streaming( ) = _refresh_context_and_revision_from_state_db() _heal_kwargs = _build_run_conversation_kwargs( agent.run_conversation, + session=s, user_message=user_message, system_message=workspace_system_msg, conversation_history=_sanitize_messages_for_agent( @@ -13654,6 +13697,7 @@ def _run_agent_streaming( ) = _refresh_context_and_revision_from_state_db() _heal_kwargs2 = _build_run_conversation_kwargs( _heal_agent.run_conversation, + session=s, user_message=user_message, system_message=workspace_system_msg, conversation_history=_sanitize_messages_for_agent( diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index 5faf3808..a005c797 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -174,13 +174,25 @@ and 5; it does not mark every run-state boundary implemented. evidence. A WebUI-submitted native-image turn has a separate display owner: keep its exact submitted text and attachment in the visible session row, while the Agent's expanded multipart row remains in `context_messages` for - model replay. Match its scalar state.db projection only with trusted turn - and durable-row identity, never the marker alone. For a uniquely identified - unmatched state.db row already present in the sidecar, conflicting provider - sidecars are two versions of that *one* durable row: show it once and use - the state.db provider payload for replay. Preserve genuinely distinct or - ambiguous rows. Agent state.db alone cannot restore the original attachment - if the WebUI sidecar is lost. + model replay. While the turn is active, the WebUI may hide an Agent user row + from display only after its worker confirms that the active stream's exact + `pending_started_at` value was passed to the Agent as + `persist_user_timestamp`; persist that private proof with the session and + validate it against the pending stream, source, and timestamp after reload. + Never include the proof in public session payloads. This applies to + native-image and scalar text-attachment rows, and never changes model + context. If multiple user rows share that timestamp, omit the whole + ambiguous display bucket until the turn settles; keep all rows in model + context. Do not identify the row by its text. + Match settled native-image scalar projections only with trusted turn and + durable-row identity, never the marker alone. A durable row ID proves row + identity, not provider-payload freshness: when the sidecar and state.db have + conflicting nonempty `api_content`, preserve both versions for display and + replay without mutating either. Fill a missing payload from the other copy; + repeated reconciliation must remain bounded and idempotent. Preserve + genuinely distinct or ambiguous rows when matching settled projections. + Agent state.db alone cannot restore the original attachment if the WebUI + sidecar is lost. Visible interim assistant progress must remain visible timeline content; a compact Activity disclosure may summarize adjacent tool/debug detail, but it must not be the only place where the user can see emitted progress text. diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index 6febd954..b31b1411 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -1,7 +1,11 @@ """Native-image turns keep model context private from the visible transcript.""" import json +import shutil import sqlite3 +import subprocess +import time +from pathlib import Path from types import SimpleNamespace import pytest @@ -25,6 +29,21 @@ RECALL_NOTE = "Recall note: the sample is neutral." PLUGIN_NOTE = "Pre-call note: summarize visible details only." +def _js_function_source(src, name): + start = src.find(f"function {name}(") + assert start != -1, f"{name} not found" + brace = src.find("{", start) + depth = 0 + for index in range(brace, len(src)): + if src[index] == "{": + depth += 1 + elif src[index] == "}": + depth -= 1 + if depth == 0: + return src[start:index + 1] + raise AssertionError(f"{name} body unterminated") + + def _native_user_content(text, image_url, extra_text=()): parts = [{"type": "text", "text": f"[Workspace::v1: /fixture]\n{text}"}] if image_url: @@ -208,6 +227,368 @@ def _write_state_db(path, session_id, rows): ) +@pytest.mark.parametrize("msg_limit", [None, 5]) +@pytest.mark.parametrize("attachment_mode", ["native_image", "text_attachment"]) +def test_get_session_keeps_pending_agent_projection_private_but_in_context( + monkeypatch, tmp_path, msg_limit, attachment_mode, +): + import api.config + import api.models as models + import api.session_ops + from api import routes + + timestamp = time.time() + stream_id = "pending-webui-stream" + session_id = f"pending-display-{attachment_mode}-{'full' if msg_limit is None else 'limited'}" + prompt = "Recall note: I authored this literal text." + attachments = [ + {"name": "sample.png" if attachment_mode == "native_image" else "sample.txt", + "mime": "image/png" if attachment_mode == "native_image" else "text/plain", + "is_image": attachment_mode == "native_image"} + ] + if attachment_mode == "native_image": + agent_content = _native_user_content( + prompt, IMAGE_A, (RECALL_NOTE, PLUGIN_NOTE), + ) + stored_content = "\x00json:" + json.dumps(agent_content) + api_content = json.dumps(agent_content) + else: + agent_content = f"{prompt}\n\n{RECALL_NOTE}\n\n{PLUGIN_NOTE}" + stored_content = agent_content + api_content = None + + db_path = tmp_path / "state.db" + with sqlite3.connect(db_path) as conn: + conn.execute( + "CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, " + "content TEXT, timestamp REAL, active INTEGER DEFAULT 1, api_content TEXT)" + ) + + session_dir = tmp_path / "sessions" + session_dir.mkdir() + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") + monkeypatch.setattr(routes, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "_active_state_db_path", lambda: db_path) + session = models.Session( + session_id=session_id, + workspace="/fixture", + model="fixture-model", + context_length=128_000, + messages=[], + context_messages=[], + active_stream_id=stream_id, + pending_user_message=prompt, + pending_attachments=attachments, + pending_started_at=timestamp, + pending_user_source="webui", + source_tag="webui", + session_source="webui", + ) + session.save(skip_index=True) + + def agent_run(user_message, persist_user_timestamp=None, **_kwargs): + assert user_message == prompt + assert persist_user_timestamp == timestamp + persisted = models.Session.load(session_id) + assert persisted._webui_pending_user_timestamp_identity == (stream_id, timestamp) + with sqlite3.connect(db_path) as conn: + conn.executemany( + "INSERT INTO messages (id, session_id, role, content, timestamp, api_content) " + "VALUES (?, ?, ?, ?, ?, ?)", + [ + (1, session_id, "user", "A preserved prior turn", timestamp - 2, None), + (2, session_id, "user", stored_content, timestamp, api_content), + (3, session_id, "user", "A distinct later user row", timestamp + 2, None), + (4, "other-profile-session", "user", stored_content, timestamp, api_content), + ], + ) + + from api.streaming import _build_run_conversation_kwargs + + run_kwargs = _build_run_conversation_kwargs( + agent_run, + session=session, + user_message=prompt, + system_message="system", + conversation_history=[], + conversation_history_revision=None, + task_id=session_id, + persist_user_message=prompt, + persist_user_timestamp=timestamp, + ) + agent_run(**run_kwargs) + # A cold Session.load restores only the proof persisted before the Agent + # wrote its pending user row. + session = models.Session.load(session_id) + assert session._webui_pending_user_timestamp_identity == (stream_id, timestamp) + + response = {} + # Simulate a WebUI process restart: no stream or worker remains, but the + # pending timestamp is inside the existing stale-repair grace window. + monkeypatch.setattr(routes, "STREAMS", {}) + monkeypatch.setattr(api.config, "ACTIVE_RUNS", {}) + with models.LOCK: + models.SESSIONS.pop(session_id, None) + monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *_args: True) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: set()) + monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda *_args: {}) + monkeypatch.setattr(routes, "find_run_summary", lambda *_args: None) + monkeypatch.setattr(api.config, "load_settings", lambda: {"api_redact_enabled": False}) + monkeypatch.setattr(api.session_ops, "regeneration_state", lambda _session: ([], [])) + monkeypatch.setattr(api.session_ops, "regeneration_authority", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + routes, + "j", + lambda _handler, payload, status=200, **_kwargs: ( + response.update(payload=payload, status=status) or payload + ), + ) + query = f"session_id={session_id}&resolve_model=0" + if msg_limit is not None: + query += f"&msg_limit={msg_limit}" + routes._handle_session_get( + None, + SimpleNamespace(path="/api/session", query=query), + ) + + assert response["status"] == 200 + public_session = response["payload"]["session"] + public_messages = public_session["messages"] + assert public_session["active_stream_id"] == stream_id + assert public_session["pending_user_message"] == prompt + assert public_session["pending_attachments"] == attachments + assert prompt in json.dumps(public_session) + assert "_webui_pending_user_timestamp_identity" not in json.dumps(public_session) + assert RECALL_NOTE not in json.dumps(public_session) + assert PLUGIN_NOTE not in json.dumps(public_session) + assert all( + RECALL_NOTE not in json.dumps(message) + and PLUGIN_NOTE not in json.dumps(message) + for message in public_messages + ) + assert any(message.get("content") == "A preserved prior turn" for message in public_messages) + assert any(message.get("content") == "A distinct later user row" for message in public_messages) + assert not any(message.get("session_id") == "other-profile-session" for message in public_messages) + + state_rows = models.get_state_db_session_messages(session_id) + context = models.reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=state_rows, + ) + active_context_rows = [ + message for message in context + if message.get("role") == "user" and message.get("timestamp") == timestamp + ] + assert len(active_context_rows) == 1 + assert RECALL_NOTE in json.dumps(active_context_rows[0]["content"]) + assert PLUGIN_NOTE in json.dumps(active_context_rows[0]["content"]) + if attachment_mode == "native_image": + assert any( + part.get("type") == "image_url" + for part in active_context_rows[0]["content"] + ) + replay_rows = _sanitize_messages_for_agent(context) + assert sum( + RECALL_NOTE in json.dumps(message) + and PLUGIN_NOTE in json.dumps(message) + for message in replay_rows + ) == 1 + assert RECALL_NOTE in json.dumps(replay_rows) + assert PLUGIN_NOTE in json.dumps(replay_rows) + assert not session.messages and not session.context_messages + + # A metadata-only poll may arrive after the full load. It must not erase + # the pending attachment from the browser's session state. + response.clear() + routes._handle_session_get( + None, + SimpleNamespace( + path="/api/session", + query=f"session_id={session_id}&resolve_model=0&messages=0", + ), + ) + metadata = response["payload"]["session"] + assert metadata["pending_user_message"] == prompt + assert metadata["pending_attachments"] == attachments + assert RECALL_NOTE not in json.dumps(metadata) + assert PLUGIN_NOTE not in json.dumps(metadata) + + next_session = models.Session.load(session_id) + monkeypatch.setattr(routes, "register_session_writeback_owner", lambda *_args: None) + monkeypatch.setattr(routes, "get_webui_session_save_mode", lambda: "deferred") + routes._prepare_chat_start_session_for_stream( + next_session, + msg="A separate next turn", + attachments=[], + workspace="/fixture", + model="fixture-model", + model_provider=None, + stream_id="next-webui-stream", + started_at=timestamp + 1, + ) + assert next_session._webui_pending_user_timestamp_identity is None + assert models.Session.load(session_id)._webui_pending_user_timestamp_identity is None + + +def test_pending_row_identity_requires_timestamp_handoff_to_active_agent(): + from api.models import _suppress_native_image_display_mirrors + from api.streaming import _build_run_conversation_kwargs + + def modern_run(user_message, persist_user_timestamp=None): + return None + + def legacy_run(user_message): + return None + + def opaque_run(user_message, **kwargs): + return None + + timestamp = 1700000000.125 + session = SimpleNamespace( + active_stream_id="stream-identity", + pending_started_at=timestamp, + pending_user_message="prompt", + pending_user_source="webui", + ) + kwargs = { + "session": session, + "user_message": "prompt", + "system_message": "system", + "conversation_history": [], + "conversation_history_revision": None, + "task_id": "session", + "persist_user_message": "prompt", + "persist_user_timestamp": timestamp, + } + modern = _build_run_conversation_kwargs(modern_run, **kwargs) + assert modern["persist_user_timestamp"] == timestamp + assert session._webui_pending_user_timestamp_identity == ( + "stream-identity", timestamp, + ) + rebuilt = _build_run_conversation_kwargs(modern_run, **kwargs) + assert rebuilt["persist_user_timestamp"] == timestamp + assert session._webui_pending_user_timestamp_identity == ( + "stream-identity", timestamp, + ) + # Credential-heal fallback can replace the callable mid-turn. Proof from + # the first invocation must not authorize rows from an older Agent. + fallback = _build_run_conversation_kwargs(legacy_run, **kwargs) + assert "persist_user_timestamp" not in fallback + assert session._webui_pending_user_timestamp_identity is None + + legacy_session = SimpleNamespace( + active_stream_id="stream-identity", + pending_started_at=timestamp, + pending_user_message="prompt", + pending_user_source="webui", + ) + legacy = _build_run_conversation_kwargs( + legacy_run, **{**kwargs, "session": legacy_session} + ) + assert "persist_user_timestamp" not in legacy + assert legacy_session._webui_pending_user_timestamp_identity is None + opaque = _build_run_conversation_kwargs( + opaque_run, **{**kwargs, "session": legacy_session} + ) + assert "persist_user_timestamp" in opaque + assert legacy_session._webui_pending_user_timestamp_identity is None + row = {"role": "user", "content": "Agent-only memory", "timestamp": timestamp} + assert _suppress_native_image_display_mirrors(legacy_session, [row]) == [row] + + for field, value in ( + ("active_stream_id", "another-stream"), + ("pending_user_source", "cli"), + ("pending_started_at", timestamp + 1), + ("pending_user_message", None), + ): + mismatched = SimpleNamespace( + active_stream_id="stream-identity", + pending_started_at=timestamp, + pending_user_message="prompt", + pending_user_source="webui", + _webui_pending_user_timestamp_identity=("stream-identity", timestamp), + ) + setattr(mismatched, field, value) + assert _suppress_native_image_display_mirrors(mismatched, [row]) == [row] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not on PATH") +def test_clean_pending_get_payload_renders_submitted_prompt_and_attachment(): + root = Path(__file__).resolve().parents[1] + ui_js = (root / "static" / "ui.js").read_text(encoding="utf-8") + sessions_js = (root / "static" / "sessions.js").read_text(encoding="utf-8") + helpers = "\n".join( + [ + *[ + _js_function_source(sessions_js, name) + for name in ( + "_messageComparableText", + "_stripAttachedFilesMarker", + "_stripForcedSkillEnvelope", + "_normalizeUserTranscriptText", + "_sameTranscriptMessage", + "_currentTailUserMessage", + "_hasCurrentTailUserDuplicate", + "_mergePendingSessionMessage", + ) + ], + *[ + _js_function_source(ui_js, name) + for name in ( + "_pendingCurrentTailUserMessage", + "_messageTimestampSeconds", + "_activeTurnTokenMatches", + "_pendingActiveTurnUserMessage", + "getPendingSessionMessage", + ) + ], + ] + ) + payload = { + "session_id": "pending-display-session", + "active_stream_id": "pending-webui-stream", + "pending_started_at": 1700000000.125, + "pending_user_source": "webui", + "pending_user_message": "Recall note: I authored this literal text.", + "pending_attachments": [{"name": "literal-note.txt", "mime": "text/plain"}], + "messages": [], + } + assert "_mergePendingSessionMessage(S.session,S.messages)" in sessions_js + assert "_mergePendingSessionMessage(data.session, S.messages)" in ui_js + assert "await refreshSession();" in ui_js + script = f""" +{helpers} +const session={json.dumps(payload)}; +const messages=session.messages; +const inserted=_mergePendingSessionMessage(session,messages); +const duplicate=_mergePendingSessionMessage(session,messages); +process.stdout.write(JSON.stringify({{inserted,duplicate,messages}})); +""" + completed = subprocess.run( + [shutil.which("node"), "-e", script], + cwd=root, + text=True, + capture_output=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr or completed.stdout + result = json.loads(completed.stdout) + assert result["inserted"] is True + assert result["duplicate"] is False + assert result["messages"] == [ + { + "role": "user", + "content": "Recall note: I authored this literal text.", + "attachments": [{"name": "literal-note.txt", "mime": "text/plain"}], + "_ts": 1700000000.125, + "_pending": True, + "_source": "webui", + } + ] + + def test_settlement_reload_and_next_turn_keep_one_clean_bubble_and_rich_context( monkeypatch, tmp_path ): @@ -801,7 +1182,16 @@ def test_marked_native_image_mirror_repairs_malformed_sidecar_once(): assert replay_rows[0]["api_content"] == api_content -def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery(): +@pytest.mark.parametrize( + ("sidecar_payload", "state_payload"), + [ + ("OLDER-SIDECAR-BYTES", "NEWER-DB-BYTES"), + ("NEWER-SIDECAR-BYTES", "OLDER-DB-BYTES"), + ], +) +def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery( + sidecar_payload, state_payload, +): import api.models as models timestamp = 860.0 @@ -814,19 +1204,21 @@ def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery(): if message.get("_active_turn_token") == identity["token"] ) mirror = _durable_agent_content(context_user["content"]) - session.messages.append({ + sidecar_mirror_row = { "role": "user", "content": mirror, "timestamp": timestamp, "_state_db_row_id": 42, - "api_content": "OLD-PROVIDER-BYTES", - }) + "api_content": sidecar_payload, + } + session.messages.append(dict(sidecar_mirror_row)) + session.context_messages.append(dict(sidecar_mirror_row)) state_row = { "role": "user", "content": mirror, "timestamp": timestamp, "_state_db_row_id": 42, - "api_content": "NEW-PROVIDER-BYTES", + "api_content": state_payload, } marked = models._suppress_native_image_display_mirrors(session, [state_row]) assert marked[0]["_webui_unmatched_native_image_mirror"] is True @@ -851,15 +1243,19 @@ def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery(): message for message in context if message.get("_state_db_row_id") == 42 ] - assert len(display_row_42) == 1 - assert display_row_42[0]["api_content"] == "NEW-PROVIDER-BYTES" - assert len(context_row_42) == 1 - assert context_row_42[0]["api_content"] == "NEW-PROVIDER-BYTES" + assert len(display_row_42) == 2 + assert {message["api_content"] for message in display_row_42} == { + sidecar_payload, state_payload, + } + assert len(context_row_42) == 2 + assert {message["api_content"] for message in context_row_42} == { + sidecar_payload, state_payload, + } public = public_session_projection({"messages": display})["messages"] public_mirrors = [message for message in public if message.get("content") == mirror] - assert len(public_mirrors) == 1 - assert "api_content" not in public_mirrors[0] + assert len(public_mirrors) == 2 + assert all("api_content" not in message for message in public_mirrors) display_image_turn = next( message for message in display if message.get("_active_turn_token") == identity["token"] @@ -873,8 +1269,22 @@ def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery(): replay = _sanitize_messages_for_agent(context) replay_row_42 = [message for message in replay if message.get("content") == mirror] - assert len(replay_row_42) == 1 - assert replay_row_42[0]["api_content"] == "NEW-PROVIDER-BYTES" + assert len(replay_row_42) == 2 + assert {message["api_content"] for message in replay_row_42} == { + sidecar_payload, state_payload, + } + live_row_42 = [ + message for message in session.messages + if message.get("_state_db_row_id") == 42 + ] + if first_public is None: + assert len(live_row_42) == 1 + assert live_row_42[0]["api_content"] == sidecar_payload + else: + assert len(live_row_42) == 2 + assert {message["api_content"] for message in live_row_42} == { + sidecar_payload, state_payload, + } replay_image_turn = next( message for message in replay if isinstance(message.get("content"), list) From 0be6a1f454e544cae0ea0d6b20c9b71183f284dd Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:56:46 -0600 Subject: [PATCH 10/16] fix(chat): prevent duplicate bubbles for conflicting provider payloads Record pending-turn proof under caller-owned session locks during normal runs and credential recovery. Preserve both conflicting provider payloads for replay, but display one sidecar-owned bubble when row identity and visible content are unambiguous. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna --- api/models.py | 75 ++++++++ api/streaming.py | 107 +++++++----- .../webui-run-state-consistency-contract.md | 11 +- tests/test_compression_snapshot_revision.py | 9 +- .../test_native_image_turn_display_context.py | 164 +++++++++++++++--- 5 files changed, 290 insertions(+), 76 deletions(-) diff --git a/api/models.py b/api/models.py index 80098e71..4a17a371 100644 --- a/api/models.py +++ b/api/models.py @@ -11537,6 +11537,81 @@ def reconciled_state_db_messages_for_session( truncation_boundary=getattr(session, "truncation_boundary", None), incoming_provenance="state_db", ) + if not prefer_context: + # Keep the append-only merge's conflicting provider payloads in model + # context. For display only, a marked native-image mirror can share the + # existing sidecar bubble when its durable identity, exact timestamp, + # and exact visible user content all agree. + sidecar_row_id_counts = collections.Counter( + row_id + for message in local_messages + if (row_id := _state_db_row_identity(message)) is not None + ) + state_row_id_counts = collections.Counter( + row_id + for message in state_messages or () + if (row_id := _state_db_row_identity(message)) is not None + ) + + display_conflicts = {} + for incoming in state_messages or (): + if ( + not isinstance(incoming, dict) + or incoming.get(_WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD) is not True + ): + continue + row_id, row_id_valid = _state_db_row_identity_details(incoming) + timestamp, timestamp_valid = _message_exact_timestamp_details(incoming) + content = incoming.get("content") + if ( + not row_id_valid + or row_id is None + or sidecar_row_id_counts[row_id] != 1 + or state_row_id_counts[row_id] != 1 + or not timestamp_valid + or timestamp is None + or str(incoming.get("role") or "").lower() != "user" + or not isinstance(content, str) + ): + continue + incoming_api_content = _session_message_api_content_key(incoming) + if incoming_api_content is None: + continue + sidecar_owner = next(( + message for message in local_messages + if isinstance(message, dict) + and _state_db_row_identity_details(message) == (row_id, True) + and _message_identity_compatible(message, incoming) + and message.get("content") == content + and _message_exact_timestamp_details(message) == (timestamp, True) + and _session_message_api_content_key(message) + not in (None, incoming_api_content) + ), None) + if sidecar_owner is not None: + display_conflicts[(row_id, timestamp, content)] = ( + incoming, + sidecar_owner, + ) + + if display_conflicts: + visible_messages = [] + for message in reconciled_messages: + if isinstance(message, dict) and isinstance(message.get("content"), str): + row_id, row_id_valid = _state_db_row_identity_details(message) + timestamp, timestamp_valid = _message_exact_timestamp_details(message) + conflict = ( + display_conflicts.get((row_id, timestamp, message["content"])) + 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 + visible_messages.append(message) + reconciled_messages = visible_messages return _state_db_session_messages_result( reconciled_messages, state_revision, diff --git a/api/streaming.py b/api/streaming.py index f4df3f2f..050ba1d0 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -9137,10 +9137,42 @@ def _add_supported_run_conversation_kwarg(callable_obj, kwargs, name, value): return False +def _register_pending_user_timestamp_identity( + callable_obj, session, persist_user_timestamp, +): + """Persist the pending-turn proof while the caller owns the session lock.""" + if session is None: + return + try: + timestamp_parameter = inspect.signature(callable_obj).parameters.get( + "persist_user_timestamp" + ) + except (TypeError, ValueError): + timestamp_parameter = None + has_timestamp_contract = ( + timestamp_parameter is not None + and timestamp_parameter.kind is not inspect.Parameter.POSITIONAL_ONLY + ) + identity = ( + _validated_webui_pending_user_timestamp_identity( + session, + (getattr(session, "active_stream_id", None), persist_user_timestamp), + ) + if has_timestamp_contract + else None + ) + previous = getattr(session, "_webui_pending_user_timestamp_identity", None) + session._webui_pending_user_timestamp_identity = identity + session_id = getattr(session, "session_id", None) + save = getattr(session, "save", None) + if callable(save) and session_id and previous != identity: + # Persist before run_conversation can write this turn to state.db. + save(touch_updated_at=False, skip_index=True) + + def _build_run_conversation_kwargs( callable_obj, *, - session=None, user_message, system_message, conversation_history, @@ -9149,7 +9181,7 @@ def _build_run_conversation_kwargs( persist_user_message, persist_user_timestamp, ): - """Build one rolling-compatible Agent invocation contract. + """Build one rolling-compatible Agent invocation contract without mutation. ``persist_user_timestamp`` is signature-gated (#6935): an older hermes-agent whose ``run_conversation()`` predates the kwarg must not @@ -9174,45 +9206,6 @@ def _build_run_conversation_kwargs( "conversation_history_revision", conversation_history_revision, ) - if session is not None: - try: - timestamp_parameter = inspect.signature(callable_obj).parameters.get( - "persist_user_timestamp" - ) - except (TypeError, ValueError): - timestamp_parameter = None - has_timestamp_contract = ( - timestamp_parameter is not None - and timestamp_parameter.kind is not inspect.Parameter.POSITIONAL_ONLY - ) - session_id = getattr(session, "session_id", None) - save = getattr(session, "save", None) - lock = ( - _get_session_agent_lock(session_id) - if callable(save) and session_id - else contextlib.nullcontext() - ) - with lock: - candidate = ( - _validated_webui_pending_user_timestamp_identity( - session, - ( - getattr(session, "active_stream_id", None), - kwargs.get("persist_user_timestamp"), - ), - ) - if has_timestamp_contract - else None - ) - # A retry using an older/opaque Agent must revoke earlier proof; - # row identity cannot be carried across a changed invocation contract. - identity = candidate - previous = getattr(session, "_webui_pending_user_timestamp_identity", None) - session._webui_pending_user_timestamp_identity = identity - if callable(save) and session_id and previous != identity: - # Persist the proof while holding the session mutation lock, - # before run_conversation can write this turn to state.db. - save(touch_updated_at=False, skip_index=True) return kwargs @@ -11763,9 +11756,13 @@ def _run_agent_streaming( _agent_msg_text = "\n\n".join([*_process_notifications, msg_text]).strip() user_message = _build_native_multimodal_message(workspace_ctx, _agent_msg_text, attachments, workspace, cfg=_cfg, active_provider=(resolved_provider or ""), active_model=(resolved_model or ""), requested_provider=(_session_requested_provider or ""), profile=(getattr(s, "profile", None) or Path(_profile_home))) _persistent_state_before = _persistent_state_snapshot(_profile_home) + with _agent_lock: + _persist_user_timestamp = getattr(s, 'pending_started_at', None) + _register_pending_user_timestamp_identity( + agent.run_conversation, s, _persist_user_timestamp + ) _run_conversation_kwargs = _build_run_conversation_kwargs( agent.run_conversation, - session=s, user_message=user_message, system_message=workspace_system_msg, conversation_history=_sanitize_messages_for_agent( @@ -11779,7 +11776,7 @@ def _run_agent_streaming( conversation_history_revision=_conversation_history_revision, task_id=session_id, persist_user_message=msg_text, - persist_user_timestamp=getattr(s, 'pending_started_at', None), + persist_user_timestamp=_persist_user_timestamp, ) # Only pass moa_config when a /moa override is actually active, so a # normal send never trips a TypeError on an older hermes-agent whose @@ -12343,9 +12340,17 @@ def _run_agent_streaming( _heal_context_messages, _heal_conversation_history_revision, ) = _refresh_context_and_revision_from_state_db() + _heal_persist_user_timestamp = getattr( + s, 'pending_started_at', None + ) + # The returned-error path already owns _agent_lock. + _register_pending_user_timestamp_identity( + agent.run_conversation, + s, + _heal_persist_user_timestamp, + ) _heal_kwargs = _build_run_conversation_kwargs( agent.run_conversation, - session=s, user_message=user_message, system_message=workspace_system_msg, conversation_history=_sanitize_messages_for_agent( @@ -12361,7 +12366,7 @@ def _run_agent_streaming( ), task_id=session_id, persist_user_message=msg_text, - persist_user_timestamp=getattr(s, 'pending_started_at', None), + persist_user_timestamp=_heal_persist_user_timestamp, ) if moa_config is not None: _heal_kwargs["moa_config"] = moa_config @@ -13695,9 +13700,17 @@ def _run_agent_streaming( _heal_context_messages, _heal_conversation_history_revision, ) = _refresh_context_and_revision_from_state_db() + with _agent_lock: + _heal_persist_user_timestamp = getattr( + s, 'pending_started_at', None + ) + _register_pending_user_timestamp_identity( + _heal_agent.run_conversation, + s, + _heal_persist_user_timestamp, + ) _heal_kwargs2 = _build_run_conversation_kwargs( _heal_agent.run_conversation, - session=s, user_message=user_message, system_message=workspace_system_msg, conversation_history=_sanitize_messages_for_agent( @@ -13713,7 +13726,7 @@ def _run_agent_streaming( ), task_id=session_id, persist_user_message=msg_text, - persist_user_timestamp=getattr(s, 'pending_started_at', None), + persist_user_timestamp=_heal_persist_user_timestamp, ) if moa_config is not None: _heal_kwargs2["moa_config"] = moa_config diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index a005c797..252756e8 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -187,10 +187,13 @@ and 5; it does not mark every run-state boundary implemented. Match settled native-image scalar projections only with trusted turn and durable-row identity, never the marker alone. A durable row ID proves row identity, not provider-payload freshness: when the sidecar and state.db have - conflicting nonempty `api_content`, preserve both versions for display and - replay without mutating either. Fill a missing payload from the other copy; - repeated reconciliation must remain bounded and idempotent. Preserve - genuinely distinct or ambiguous rows when matching settled projections. + conflicting nonempty `api_content`, preserve both versions for model-context + replay without mutating either. For visible display, a marked mirror may + share the existing sidecar bubble only when its valid durable row ID, exact + timestamp, and exact visible user content match; keep the sidecar-owned row + and its display metadata. Distinct row IDs, ambiguous or invalid identities, + and different visible user text remain separate. Fill a missing payload from + the other copy; repeated reconciliation must remain bounded and idempotent. Agent state.db alone cannot restore the original attachment if the WebUI sidecar is lost. Visible interim assistant progress must remain visible timeline content; a diff --git a/tests/test_compression_snapshot_revision.py b/tests/test_compression_snapshot_revision.py index 7d0b9411..be1c57a4 100644 --- a/tests/test_compression_snapshot_revision.py +++ b/tests/test_compression_snapshot_revision.py @@ -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: diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index b31b1411..40402502 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -236,6 +236,10 @@ def test_get_session_keeps_pending_agent_projection_private_but_in_context( import api.models as models import api.session_ops from api import routes + from api.streaming import ( + _build_run_conversation_kwargs, + _register_pending_user_timestamp_identity, + ) timestamp = time.time() stream_id = "pending-webui-stream" @@ -304,11 +308,10 @@ def test_get_session_keeps_pending_agent_projection_private_but_in_context( ], ) - from api.streaming import _build_run_conversation_kwargs - + with api.config._get_session_agent_lock(session_id): + _register_pending_user_timestamp_identity(agent_run, session, timestamp) run_kwargs = _build_run_conversation_kwargs( agent_run, - session=session, user_message=prompt, system_message="system", conversation_history=[], @@ -434,7 +437,11 @@ def test_get_session_keeps_pending_agent_projection_private_but_in_context( def test_pending_row_identity_requires_timestamp_handoff_to_active_agent(): from api.models import _suppress_native_image_display_mirrors - from api.streaming import _build_run_conversation_kwargs + from api.streaming import ( + _build_run_conversation_kwargs, + _register_pending_user_timestamp_identity, + _get_session_agent_lock, + ) def modern_run(user_message, persist_user_timestamp=None): return None @@ -453,7 +460,6 @@ def test_pending_row_identity_requires_timestamp_handoff_to_active_agent(): pending_user_source="webui", ) kwargs = { - "session": session, "user_message": "prompt", "system_message": "system", "conversation_history": [], @@ -462,11 +468,15 @@ def test_pending_row_identity_requires_timestamp_handoff_to_active_agent(): "persist_user_message": "prompt", "persist_user_timestamp": timestamp, } + with _get_session_agent_lock("session"): + _register_pending_user_timestamp_identity(modern_run, session, timestamp) modern = _build_run_conversation_kwargs(modern_run, **kwargs) assert modern["persist_user_timestamp"] == timestamp assert session._webui_pending_user_timestamp_identity == ( "stream-identity", timestamp, ) + with _get_session_agent_lock("session"): + _register_pending_user_timestamp_identity(modern_run, session, timestamp) rebuilt = _build_run_conversation_kwargs(modern_run, **kwargs) assert rebuilt["persist_user_timestamp"] == timestamp assert session._webui_pending_user_timestamp_identity == ( @@ -474,6 +484,8 @@ def test_pending_row_identity_requires_timestamp_handoff_to_active_agent(): ) # Credential-heal fallback can replace the callable mid-turn. Proof from # the first invocation must not authorize rows from an older Agent. + with _get_session_agent_lock("session"): + _register_pending_user_timestamp_identity(legacy_run, session, timestamp) fallback = _build_run_conversation_kwargs(legacy_run, **kwargs) assert "persist_user_timestamp" not in fallback assert session._webui_pending_user_timestamp_identity is None @@ -484,14 +496,18 @@ def test_pending_row_identity_requires_timestamp_handoff_to_active_agent(): pending_user_message="prompt", pending_user_source="webui", ) - legacy = _build_run_conversation_kwargs( - legacy_run, **{**kwargs, "session": legacy_session} - ) + with _get_session_agent_lock("session"): + _register_pending_user_timestamp_identity( + legacy_run, legacy_session, timestamp + ) + legacy = _build_run_conversation_kwargs(legacy_run, **kwargs) assert "persist_user_timestamp" not in legacy assert legacy_session._webui_pending_user_timestamp_identity is None - opaque = _build_run_conversation_kwargs( - opaque_run, **{**kwargs, "session": legacy_session} - ) + with _get_session_agent_lock("session"): + _register_pending_user_timestamp_identity( + opaque_run, legacy_session, timestamp + ) + opaque = _build_run_conversation_kwargs(opaque_run, **kwargs) assert "persist_user_timestamp" in opaque assert legacy_session._webui_pending_user_timestamp_identity is None row = {"role": "user", "content": "Agent-only memory", "timestamp": timestamp} @@ -1204,12 +1220,18 @@ def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery( if message.get("_active_turn_token") == identity["token"] ) mirror = _durable_agent_content(context_user["content"]) + display_attachments = [{ + "name": "sidecar-owned.png", + "mime": "image/png", + "is_image": True, + }] sidecar_mirror_row = { "role": "user", "content": mirror, "timestamp": timestamp, "_state_db_row_id": 42, "api_content": sidecar_payload, + "attachments": display_attachments, } session.messages.append(dict(sidecar_mirror_row)) session.context_messages.append(dict(sidecar_mirror_row)) @@ -1243,10 +1265,9 @@ def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery( message for message in context if message.get("_state_db_row_id") == 42 ] - assert len(display_row_42) == 2 - assert {message["api_content"] for message in display_row_42} == { - sidecar_payload, state_payload, - } + assert len(display_row_42) == 1 + assert display_row_42[0]["api_content"] == sidecar_payload + assert display_row_42[0]["attachments"] == display_attachments assert len(context_row_42) == 2 assert {message["api_content"] for message in context_row_42} == { sidecar_payload, state_payload, @@ -1254,8 +1275,9 @@ def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery( public = public_session_projection({"messages": display})["messages"] public_mirrors = [message for message in public if message.get("content") == mirror] - assert len(public_mirrors) == 2 + assert len(public_mirrors) == 1 assert all("api_content" not in message for message in public_mirrors) + assert public_mirrors[0]["attachments"] == display_attachments display_image_turn = next( message for message in display if message.get("_active_turn_token") == identity["token"] @@ -1277,14 +1299,8 @@ def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery( message for message in session.messages if message.get("_state_db_row_id") == 42 ] - if first_public is None: - assert len(live_row_42) == 1 - assert live_row_42[0]["api_content"] == sidecar_payload - else: - assert len(live_row_42) == 2 - assert {message["api_content"] for message in live_row_42} == { - sidecar_payload, state_payload, - } + assert len(live_row_42) == 1 + assert live_row_42[0]["api_content"] == sidecar_payload replay_image_turn = next( message for message in replay if isinstance(message.get("content"), list) @@ -1306,6 +1322,106 @@ def test_marked_native_image_mirror_conflict_stays_bounded_across_recovery( session.context_messages = context +def test_native_image_payload_conflict_with_distinct_visible_text_stays_separate(): + import api.models as models + + timestamp = 865.0 + session, identity, _ = _settle_image_turn(timestamp=timestamp, agent_row_id=41) + context_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + mirror = _durable_agent_content(context_user["content"]) + sidecar_row = { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 42, + "api_content": "SIDECAR-PAYLOAD", + } + session.messages.append(sidecar_row) + state_row = { + "role": "user", + "content": "A separate visible submission", + "timestamp": timestamp, + "_state_db_row_id": 42, + "api_content": "STATE-DB-PAYLOAD", + "_webui_unmatched_native_image_mirror": True, + } + + display = models.reconciled_state_db_messages_for_session( + session, + state_messages=[state_row], + ) + same_row = [ + message for message in display + if message.get("_state_db_row_id") == 42 + ] + assert {message["content"] for message in same_row} == { + mirror, + "A separate visible submission", + } + + +def test_marked_native_image_conflict_keeps_ambiguous_sidecar_row_id_visible(): + import api.models as models + + timestamp = 867.0 + session, identity, _ = _settle_image_turn(timestamp=timestamp, agent_row_id=41) + context_user = next( + message for message in session.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + mirror = _durable_agent_content(context_user["content"]) + sidecar_rows = [ + { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 42, + "api_content": "SIDECAR-PAYLOAD-ONE", + "attachments": [{"name": "first.png"}], + }, + { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 42, + "api_content": "SIDECAR-PAYLOAD-TWO", + "attachments": [{"name": "second.png"}], + }, + ] + session.messages.extend(sidecar_rows) + state_row = { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 42, + "api_content": "STATE-DB-PAYLOAD", + } + marked = models._suppress_native_image_display_mirrors(session, [state_row]) + assert marked[0]["_webui_unmatched_native_image_mirror"] is True + + display = models.reconciled_state_db_messages_for_session( + session, + state_messages=[state_row], + ) + same_row = [ + message for message in display + if message.get("_state_db_row_id") == 42 + ] + assert len(same_row) == 3 + assert {message["api_content"] for message in same_row} == { + "SIDECAR-PAYLOAD-ONE", + "SIDECAR-PAYLOAD-TWO", + "STATE-DB-PAYLOAD", + } + assert [message.get("attachments") for message in same_row[:2]] == [ + [{"name": "first.png"}], + [{"name": "second.png"}], + ] + + def test_marked_native_image_rows_with_distinct_ids_survive_recovery(): import api.models as models From e1cab2b6bd435c0254c467b1785e5b22245602c8 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:24:59 -0600 Subject: [PATCH 11/16] fix(chat): project payload conflicts in session reloads Apply the same display-only conflict projection to full and paginated session reads before cache and window selection. Keep both provider payloads for model replay while retaining a single visible sidecar bubble. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna --- api/models.py | 157 ++++++++++-------- api/routes.py | 15 +- .../test_native_image_turn_display_context.py | 143 ++++++++++++++++ 3 files changed, 241 insertions(+), 74 deletions(-) diff --git a/api/models.py b/api/models.py index 4a17a371..b14e4f79 100644 --- a/api/models.py +++ b/api/models.py @@ -10717,6 +10717,86 @@ def merge_session_messages_append_only( _STRUCTURED_IDENTITY_MEMO.reset(token) +def _project_native_image_payload_conflicts_for_display( + sidecar_messages, + state_messages, + merged_messages, +): + """Project a proven same-row native-image conflict onto its sidecar bubble.""" + sidecar_messages = list(sidecar_messages or []) + state_messages = list(state_messages or []) + if not sidecar_messages or not state_messages: + return merged_messages + + sidecar_row_id_counts = collections.Counter( + row_id + for message in sidecar_messages + if (row_id := _state_db_row_identity(message)) is not None + ) + state_row_id_counts = collections.Counter( + row_id + for message in state_messages + if (row_id := _state_db_row_identity(message)) is not None + ) + display_conflicts = {} + for incoming in state_messages: + if ( + not isinstance(incoming, dict) + or incoming.get(_WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD) is not True + ): + continue + row_id, row_id_valid = _state_db_row_identity_details(incoming) + timestamp, timestamp_valid = _message_exact_timestamp_details(incoming) + content = incoming.get("content") + if ( + not row_id_valid + or row_id is None + or sidecar_row_id_counts[row_id] != 1 + or state_row_id_counts[row_id] != 1 + or not timestamp_valid + or timestamp is None + or str(incoming.get("role") or "").lower() != "user" + or not isinstance(content, str) + ): + continue + incoming_api_content = _session_message_api_content_key(incoming) + if incoming_api_content is None: + continue + sidecar_owner = next(( + message for message in sidecar_messages + if isinstance(message, dict) + and _state_db_row_identity_details(message) == (row_id, True) + and _message_identity_compatible(message, incoming) + and message.get("content") == content + and _message_exact_timestamp_details(message) == (timestamp, True) + and _session_message_api_content_key(message) + not in (None, incoming_api_content) + ), None) + if sidecar_owner is not None: + display_conflicts[(row_id, timestamp, content)] = (incoming, sidecar_owner) + + if not display_conflicts: + return merged_messages + visible_messages = [] + for message in merged_messages: + if isinstance(message, dict) and isinstance(message.get("content"), str): + row_id, row_id_valid = _state_db_row_identity_details(message) + timestamp, timestamp_valid = _message_exact_timestamp_details(message) + conflict = ( + display_conflicts.get((row_id, timestamp, message["content"])) + 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 + visible_messages.append(message) + return visible_messages + + def _merge_session_messages_append_only_impl( sidecar_messages: list, state_messages: list, @@ -11538,80 +11618,11 @@ def reconciled_state_db_messages_for_session( incoming_provenance="state_db", ) if not prefer_context: - # Keep the append-only merge's conflicting provider payloads in model - # context. For display only, a marked native-image mirror can share the - # existing sidecar bubble when its durable identity, exact timestamp, - # and exact visible user content all agree. - sidecar_row_id_counts = collections.Counter( - row_id - for message in local_messages - if (row_id := _state_db_row_identity(message)) is not None + reconciled_messages = _project_native_image_payload_conflicts_for_display( + local_messages, + state_messages, + reconciled_messages, ) - state_row_id_counts = collections.Counter( - row_id - for message in state_messages or () - if (row_id := _state_db_row_identity(message)) is not None - ) - - display_conflicts = {} - for incoming in state_messages or (): - if ( - not isinstance(incoming, dict) - or incoming.get(_WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD) is not True - ): - continue - row_id, row_id_valid = _state_db_row_identity_details(incoming) - timestamp, timestamp_valid = _message_exact_timestamp_details(incoming) - content = incoming.get("content") - if ( - not row_id_valid - or row_id is None - or sidecar_row_id_counts[row_id] != 1 - or state_row_id_counts[row_id] != 1 - or not timestamp_valid - or timestamp is None - or str(incoming.get("role") or "").lower() != "user" - or not isinstance(content, str) - ): - continue - incoming_api_content = _session_message_api_content_key(incoming) - if incoming_api_content is None: - continue - sidecar_owner = next(( - message for message in local_messages - if isinstance(message, dict) - and _state_db_row_identity_details(message) == (row_id, True) - and _message_identity_compatible(message, incoming) - and message.get("content") == content - and _message_exact_timestamp_details(message) == (timestamp, True) - and _session_message_api_content_key(message) - not in (None, incoming_api_content) - ), None) - if sidecar_owner is not None: - display_conflicts[(row_id, timestamp, content)] = ( - incoming, - sidecar_owner, - ) - - if display_conflicts: - visible_messages = [] - for message in reconciled_messages: - if isinstance(message, dict) and isinstance(message.get("content"), str): - row_id, row_id_valid = _state_db_row_identity_details(message) - timestamp, timestamp_valid = _message_exact_timestamp_details(message) - conflict = ( - display_conflicts.get((row_id, timestamp, message["content"])) - 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 - visible_messages.append(message) - reconciled_messages = visible_messages return _state_db_session_messages_result( reconciled_messages, state_revision, diff --git a/api/routes.py b/api/routes.py index 55a16815..0eb4867a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -9304,6 +9304,7 @@ def _limited_webui_messages_for_display_with_sidecar( ) if not state_db_messages: return sidecar_messages + # NOTE: do not short-circuit to the sidecar when state.db has no strictly # newer rows. A state.db row whose timestamp is at-or-before the sidecar's # newest (recovery / edited-in-place / missing-timestamp cases) is still @@ -9364,6 +9365,11 @@ def _limited_webui_messages_for_display_with_sidecar( truncation_boundary=getattr(session, "truncation_boundary", None), incoming_provenance="state_db", ) + merged = _project_native_image_payload_conflicts_for_display( + sidecar_messages, + state_db_messages, + merged, + ) if cache_key is not None: _state_key = cache_key[4] _streaming_key = ( @@ -10580,6 +10586,7 @@ from api.models import ( get_state_db_session_message_keys_before_timestamp, get_state_db_session_summary, merge_session_messages_append_only, + _project_native_image_payload_conflicts_for_display, _suppress_native_image_display_mirrors, _reconcile_api_content_sidecars, _enrich_sidebar_lineage_metadata, @@ -13325,12 +13332,18 @@ def _handle_session_get(handler, parsed) -> bool: s, state_db_messages, ) + sidecar_messages = _webui_sidecar_lineage_messages_for_display(s) _all_msgs = merge_session_messages_append_only( - _webui_sidecar_lineage_messages_for_display(s), + sidecar_messages, state_db_messages, truncation_watermark=getattr(s, "truncation_watermark", None), truncation_boundary=getattr(s, "truncation_boundary", None), ) + _all_msgs = _project_native_image_payload_conflicts_for_display( + sidecar_messages, + state_db_messages, + _all_msgs, + ) _all_msgs = _merged_webui_lineage_messages_for_display(s, _all_msgs) else: if is_messaging_session and cli_messages: diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index 40402502..b6e6fa7b 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -227,6 +227,149 @@ def _write_state_db(path, session_id, rows): ) +@pytest.mark.parametrize("msg_limit", [None, 5]) +def test_get_session_projects_marked_payload_conflict_in_full_and_limited_paths( + monkeypatch, tmp_path, msg_limit, +): + import api.config + import api.models as models + import api.session_ops + from api import routes + + session_id = f"native-image-route-{msg_limit}" + timestamp = 880.0 + seed, identity, _ = _settle_image_turn( + session_id=session_id, + timestamp=timestamp, + agent_row_id=41, + ) + context_user = next( + message for message in seed.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + mirror = _durable_agent_content(context_user["content"]) + sidecar_payload = "SIDE-CAR-PROVIDER-PAYLOAD" + state_payload = "STATE-DB-PROVIDER-PAYLOAD" + sidecar_attachments = [{ + "name": "sidecar-owned.png", + "mime": "image/png", + "is_image": True, + }] + sidecar_mirror = { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 1, + "api_content": sidecar_payload, + "attachments": sidecar_attachments, + } + seed.messages.append(dict(sidecar_mirror)) + seed.context_messages.append(dict(sidecar_mirror)) + + session_dir = tmp_path / "sessions" + session_dir.mkdir() + db_path = tmp_path / "state.db" + _write_state_db(db_path, session_id, [(mirror, timestamp, state_payload)]) + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") + monkeypatch.setattr(routes, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "_active_state_db_path", lambda: db_path) + session = models.Session( + session_id=session_id, + workspace="/fixture", + model="fixture-model", + context_length=128_000, + messages=seed.messages, + context_messages=seed.context_messages, + source_tag="webui", + session_source="webui", + ) + session.save(skip_index=True) + with models.LOCK: + models.SESSIONS.pop(session_id, None) + + monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *_: True) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: set()) + monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda *_: {}) + monkeypatch.setattr(routes, "find_run_summary", lambda *_: None) + monkeypatch.setattr(api.config, "load_settings", lambda: {"api_redact_enabled": False}) + monkeypatch.setattr(api.session_ops, "regeneration_state", lambda _session: ([], [])) + monkeypatch.setattr( + api.session_ops, + "regeneration_authority", + lambda *_args, **_kwargs: None, + ) + response = {} + monkeypatch.setattr( + routes, + "j", + lambda _handler, payload, status=200, **_kwargs: ( + response.update(payload=payload, status=status) or payload + ), + ) + query = f"session_id={session_id}&resolve_model=0" + if msg_limit is not None: + query += f"&msg_limit={msg_limit}" + routes._handle_session_get(None, SimpleNamespace(path="/api/session", query=query)) + + assert response["status"] == 200 + public_session = response["payload"]["session"] + public_messages = public_session["messages"] + mirror_rows = [message for message in public_messages if message.get("content") == mirror] + assert len(mirror_rows) == 1, f"expected one visible mirror, found {len(mirror_rows)}" + assert all("api_content" not in message for message in mirror_rows) + assert public_session["message_count"] == 3 + assert mirror_rows[0]["attachments"] == sidecar_attachments + + state_rows = models.get_state_db_session_messages(session_id) + replay_context = models.reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=state_rows, + ) + replay_rows = [ + message for message in _sanitize_messages_for_agent(replay_context) + if message.get("content") == mirror + ] + assert len(replay_rows) == 2 + assert {message["api_content"] for message in replay_rows} == { + sidecar_payload, + state_payload, + } + + +def test_limited_conflict_projection_keeps_state_row_when_owner_is_outside_slice(): + from api import routes + + timestamp = 881.0 + mirror = "Describe this image [screenshot]" + sidecar_slice = [{"role": "user", "content": "Earlier submission", "timestamp": 879.0}] + marked_state_row = { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 42, + "api_content": "STATE-DB-PROVIDER-PAYLOAD", + "_webui_unmatched_native_image_mirror": True, + } + session = SimpleNamespace( + session_id="limited-conflict-owner-outside-page", + messages=sidecar_slice, + context_messages=[], + ) + merged = routes._limited_webui_messages_for_display_with_sidecar( + session, + sidecar_slice, + [marked_state_row], + state_db_signature=None, + msg_before=1, + ) + assert any(message.get("content") == "Earlier submission" for message in merged) + state_rows = [message for message in merged if message.get("content") == mirror] + assert len(state_rows) == 1 + assert state_rows[0]["api_content"] == marked_state_row["api_content"] + + @pytest.mark.parametrize("msg_limit", [None, 5]) @pytest.mark.parametrize("attachment_mode", ["native_image", "text_attachment"]) def test_get_session_keeps_pending_agent_projection_private_but_in_context( From 4f2bdd2dffac090565807b3638cd942b3894f879 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:48:29 -0600 Subject: [PATCH 12/16] fix(chat): include parent sidecar rows in conflict display Project conflicting provider payloads after eligible parent-only display rows are stitched, so a partial continuation keeps the parent bubble and its attachments without duplicating the durable user row. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna --- api/routes.py | 8 +- .../test_native_image_turn_display_context.py | 125 ++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/api/routes.py b/api/routes.py index 0eb4867a..85551061 100644 --- a/api/routes.py +++ b/api/routes.py @@ -13333,18 +13333,22 @@ def _handle_session_get(handler, parsed) -> bool: state_db_messages, ) sidecar_messages = _webui_sidecar_lineage_messages_for_display(s) + projection_sidecar_messages = _merged_webui_lineage_messages_for_display( + s, + sidecar_messages, + ) _all_msgs = merge_session_messages_append_only( sidecar_messages, state_db_messages, 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 = _project_native_image_payload_conflicts_for_display( - sidecar_messages, + projection_sidecar_messages, state_db_messages, _all_msgs, ) - _all_msgs = _merged_webui_lineage_messages_for_display(s, _all_msgs) else: if is_messaging_session and cli_messages: _all_msgs = _merged_session_messages_for_display(s, cli_messages) diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index b6e6fa7b..b3d44166 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -338,6 +338,131 @@ def test_get_session_projects_marked_payload_conflict_in_full_and_limited_paths( } +def test_get_session_projects_parent_only_payload_conflict_without_losing_parent_rows( + monkeypatch, tmp_path, +): + import api.config + import api.models as models + import api.session_ops + from api import routes + + session_id = "native-image-lineage-child" + parent_id = "native-image-lineage-parent" + timestamp = 890.0 + seed, identity, _ = _settle_image_turn( + session_id=session_id, + timestamp=timestamp, + agent_row_id=41, + ) + context_user = next( + message for message in seed.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + mirror = _durable_agent_content(context_user["content"]) + sidecar_payload = "PARENT-SIDECAR-PROVIDER-PAYLOAD" + state_payload = "CHILD-STATE-DB-PROVIDER-PAYLOAD" + sidecar_attachments = [{ + "name": "parent-owned.png", + "mime": "image/png", + "is_image": True, + }] + parent_mirror = { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 1, + "api_content": sidecar_payload, + "attachments": sidecar_attachments, + } + seed.context_messages.append(dict(parent_mirror)) + + session_dir = tmp_path / "sessions" + session_dir.mkdir() + db_path = tmp_path / "state.db" + _write_state_db(db_path, session_id, [(mirror, timestamp, state_payload)]) + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") + monkeypatch.setattr(routes, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "_active_state_db_path", lambda: db_path) + parent = models.Session( + session_id=parent_id, + workspace="/fixture", + model="fixture-model", + context_length=128_000, + messages=[ + {"role": "user", "content": "Unrelated parent-only row", "timestamp": 870.0}, + dict(parent_mirror), + ], + source_tag="webui", + session_source="webui", + pre_compression_snapshot=False, + ) + parent.save(skip_index=True) + session = models.Session( + session_id=session_id, + workspace="/fixture", + model="fixture-model", + context_length=128_000, + messages=seed.messages, + context_messages=seed.context_messages, + parent_session_id=parent_id, + source_tag="webui", + session_source="webui", + ) + session.save(skip_index=True) + with models.LOCK: + models.SESSIONS.pop(session_id, None) + models.SESSIONS.pop(parent_id, None) + + monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *_: True) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: set()) + monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda *_: {}) + monkeypatch.setattr(routes, "find_run_summary", lambda *_: None) + monkeypatch.setattr(api.config, "load_settings", lambda: {"api_redact_enabled": False}) + monkeypatch.setattr(api.session_ops, "regeneration_state", lambda _session: ([], [])) + monkeypatch.setattr( + api.session_ops, + "regeneration_authority", + lambda *_args, **_kwargs: None, + ) + response = {} + monkeypatch.setattr( + routes, + "j", + lambda _handler, payload, status=200, **_kwargs: ( + response.update(payload=payload, status=status) or payload + ), + ) + routes._handle_session_get( + None, + SimpleNamespace(path="/api/session", query=f"session_id={session_id}&resolve_model=0"), + ) + + assert response["status"] == 200 + public_messages = response["payload"]["session"]["messages"] + mirror_rows = [message for message in public_messages if message.get("content") == mirror] + assert len(mirror_rows) == 1, f"expected one visible mirror, found {len(mirror_rows)}" + assert mirror_rows[0]["attachments"] == sidecar_attachments + assert "Unrelated parent-only row" in [message.get("content") for message in public_messages] + assert all("api_content" not in message for message in mirror_rows) + + state_rows = models.get_state_db_session_messages(session_id) + replay_context = models.reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=state_rows, + ) + replay_rows = [ + message for message in _sanitize_messages_for_agent(replay_context) + if message.get("content") == mirror + ] + assert len(replay_rows) == 2 + assert {message["api_content"] for message in replay_rows} == { + sidecar_payload, + state_payload, + } + + def test_limited_conflict_projection_keeps_state_row_when_owner_is_outside_slice(): from api import routes From d07c60a92aad1e290617f295be78b62bcd1e254c Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:39:11 -0600 Subject: [PATCH 13/16] 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 --- api/models.py | 88 ++++++++++++++++--- api/routes.py | 59 ++++++++----- .../webui-run-state-consistency-contract.md | 7 +- .../test_native_image_turn_display_context.py | 15 ++++ .../test_session_lost_response_regression.py | 87 ++++++++++++++++++ 5 files changed, 224 insertions(+), 32 deletions(-) diff --git a/api/models.py b/api/models.py index b14e4f79..bd63649e 100644 --- a/api/models.py +++ b/api/models.py @@ -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 diff --git a/api/routes.py b/api/routes.py index 85551061..71ef658e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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, diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index 252756e8..d7e0ffcf 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -183,7 +183,12 @@ and 5; it does not mark every run-state boundary implemented. native-image and scalar text-attachment rows, and never changes model context. If multiple user rows share that timestamp, omit the whole ambiguous display bucket until the turn settles; keep all rows in model - context. Do not identify the row by its text. + context. Do not identify the row by its text. If a stream dies before + settlement, state.db self-heal must save the submitted prompt and attachments + as a visible sidecar row before clearing pending metadata; the Agent row + remains available in model context. A partial continuation must use one + consistent parent snapshot when projecting a conflicting provider payload + onto its sidecar-owned display row. Match settled native-image scalar projections only with trusted turn and durable-row identity, never the marker alone. A durable row ID proves row identity, not provider-payload freshness: when the sidecar and state.db have diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index b3d44166..cf3de3a0 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -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 diff --git a/tests/test_session_lost_response_regression.py b/tests/test_session_lost_response_regression.py index f37da008..bbbc2dd2 100644 --- a/tests/test_session_lost_response_regression.py +++ b/tests/test_session_lost_response_regression.py @@ -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): From c101b54c0238a03b9b68a85e78e0d62639875361 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:06:58 -0600 Subject: [PATCH 14/16] fix(chat): preserve journal recovery after a dead image turn Count a proved WebUI-owned pending row in the display baseline before accepting a state.db self-heal. Without assistant output past that turn, leave pending state for the run journal to restore partial text and the interruption marker instead of clearing it prematurely. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna Assisted-by: Claude Code:claude-opus-5-5 --- api/models.py | 9 +- .../webui-run-state-consistency-contract.md | 10 ++- .../test_session_lost_response_regression.py | 86 +++++++++++++++++++ 3 files changed, 96 insertions(+), 9 deletions(-) diff --git a/api/models.py b/api/models.py index bd63649e..b1b04471 100644 --- a/api/models.py +++ b/api/models.py @@ -4095,15 +4095,14 @@ def _sync_sidecar_from_state_db_if_newer(session) -> bool: locked_messages.insert(0, pending_row) locked.messages = locked_messages + display_baseline_count = len(locked_messages) merged_messages = reconciled_state_db_messages_for_session( locked, state_messages=state_messages, ) - # The reconciler is append-only: a genuine state.db advance (output the - # lost stream never wrote back) shows up as MORE rows than the sidecar. - # A merged length not greater than the sidecar means nothing new to - # recover — leave the sidecar untouched rather than rewriting in place. - if len(merged_messages) <= locked_count: + # The baseline includes any proven WebUI-owned pending display row. Only + # a later state.db row is an advance worth committing and clearing pending. + if len(merged_messages) <= display_baseline_count: return False if merged_context is None: merged_context = reconciled_state_db_messages_for_session( diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index d7e0ffcf..b6301765 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -185,10 +185,12 @@ and 5; it does not mark every run-state boundary implemented. ambiguous display bucket until the turn settles; keep all rows in model context. Do not identify the row by its text. If a stream dies before settlement, state.db self-heal must save the submitted prompt and attachments - as a visible sidecar row before clearing pending metadata; the Agent row - remains available in model context. A partial continuation must use one - consistent parent snapshot when projecting a conflicting provider payload - onto its sidecar-owned display row. + as a visible sidecar row before clearing pending metadata only when there is + genuine state.db output beyond that submitted turn. Otherwise, leave pending + state intact for journaled partial-output and interruption-marker recovery. + The Agent row remains available in model context. A partial continuation + must use one consistent parent snapshot when projecting a conflicting + provider payload onto its sidecar-owned display row. Match settled native-image scalar projections only with trusted turn and durable-row identity, never the marker alone. A durable row ID proves row identity, not provider-payload freshness: when the sidecar and state.db have diff --git a/tests/test_session_lost_response_regression.py b/tests/test_session_lost_response_regression.py index bbbc2dd2..df8e91fa 100644 --- a/tests/test_session_lost_response_regression.py +++ b/tests/test_session_lost_response_regression.py @@ -895,6 +895,92 @@ def test_get_session_sync_keeps_pending_image_prompt_when_agent_row_is_proven(mo 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. From 089fb57d25354408961b710eb24077d913a40c73 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:50:48 -0600 Subject: [PATCH 15/16] fix(chat): honor edit watermarks for distinct image rows Apply the existing truncation and checkpoint-order eligibility to marked state.db image rows before insertion. Keep removed turns out of full, limited, and model-context reads without changing no-watermark replay. Preserve conflicting provider payloads for a uniquely proven retained image row across Undo without restoring distinct removed rows. Assisted-by: Hermes Agent:gpt-6-sol Assisted-by: Codex:gpt-6-luna --- api/models.py | 198 +++++++++------- .../webui-run-state-consistency-contract.md | 7 +- .../test_native_image_turn_display_context.py | 215 ++++++++++++++++++ 3 files changed, 340 insertions(+), 80 deletions(-) diff --git a/api/models.py b/api/models.py index 2488c9df..98cf40a5 100644 --- a/api/models.py +++ b/api/models.py @@ -11581,6 +11581,95 @@ def _merge_session_messages_append_only_impl( and boundary_ts is not None and boundary_ts < watermark_timestamp ) + + def _state_row_is_truncated( + msg, key, content_key, timestamp, checkpoint_consumed, + *, retained_native_image_row: bool | None = None, + ): + # Skip rows ABOVE the watermark only while the sidecar has NOT advanced + # past the watermark. Because Session.save() no longer auto-clears the + # watermark, an unconditional `timestamp > watermark` skip would become + # permanent and silently drop legitimate future state.db-only recovery + # rows once the session moves forward past the edit boundary. Once the + # sidecar's own max timestamp is beyond the watermark, allow state rows + # newer than the sidecar tail to merge. + # + # The sidecar's max timestamp can also EQUAL the watermark when the new + # post-edit USER turn has been checkpointed into the sidecar (its + # timestamp == the advanced watermark) but its ASSISTANT reply exists + # only in state.db (recovery before the sidecar tail advances). In that + # state truncation_boundary < watermark proves the session is genuinely + # advanced, so the post-watermark state-only reply is legitimate + # post-edit content and must merge through (not be dropped as a replaced + # tail). The conservative skip still applies for boundary is None and + # boundary == watermark (not-advanced / legacy). + # + # CRITICAL: the boundary-advanced signal may only bypass the skip AFTER + # state replay has consumed the sidecar's visible checkpoint + # (state_replay_idx >= len(sidecar_visible_sequence)). A deleted suffix + # row with ts > watermark that appears in state.db BEFORE the edited + # checkpoint must still be skipped — otherwise the advanced signal would + # resurrect it. The sidecar-max-timestamp signal needs no such gate (a + # sidecar tail beyond the watermark is itself proof the checkpoint has + # advanced). + sidecar_advanced_past_watermark = ( + watermark_timestamp is not None + and ( + (max_sidecar_timestamp is not None + and max_sidecar_timestamp > watermark_timestamp) + or (watermark_advanced_by_boundary and checkpoint_consumed) + ) + ) + message_key_seen = key in seen_message_keys + content_key_seen = content_key in seen_content_keys + if retained_native_image_row is not None: + # A marked image mirror needs durable row proof; an equal scalar + # projection from another row cannot exempt it from the watermark. + message_key_seen = content_key_seen = retained_native_image_row + if ( + watermark_timestamp is not None + and timestamp is not None + and timestamp > watermark_timestamp + and not message_key_seen + and ( + not sidecar_advanced_past_watermark + or (max_sidecar_timestamp is not None and timestamp <= max_sidecar_timestamp) + ) + ): + return True + # When a truncation watermark is active, state.db may contain original + # messages that were replaced by Edit (old content with old timestamp). + # The timestamp-based filter above catches messages AFTER the watermark, + # but messages BEFORE it (like the original pre-edit content) slip through. + # If a state.db message's content is not present in the sidecar and its + # timestamp is before the watermark, it's a replaced/stale row — skip it. + if ( + watermark_timestamp is not None + and timestamp is not None + and timestamp < watermark_timestamp + and not message_key_seen + and not content_key_seen + ): + return True + # Same-second edit: if timestamp equals the watermark and the message + # content is not in the sidecar, it's a replaced message edited at the + # same second — skip it. The edited version (same timestamp, different + # content) is in the sidecar and survives this check. + # + # Only apply the same-second guard to user messages. An assistant reply + # (or tool message) at the same second as the watermark is a legitimate + # post-edit recovery row — the sidecar holds only the edited user + # checkpoint, so the assistant reply's content won't be in it and would + # be silently dropped without this role guard. + return ( + watermark_timestamp is not None + and timestamp is not None + and timestamp == watermark_timestamp + and not message_key_seen + and not content_key_seen + and str(msg.get("role", "")).lower() == "user" + ) + for source_message in state_messages: preserve_native_image_row = ( isinstance(source_message, dict) @@ -11607,11 +11696,37 @@ def _merge_session_messages_append_only_impl( if row_id_valid and row_id is not None else None ) - if ( + row_id_fast_path_allowed = ( existing is not None and row_id not in ambiguous_row_ids and _row_id_fast_path_allowed(existing, msg) + ) + existing_timestamp, existing_timestamp_valid = ( + _message_exact_timestamp_details(existing) + ) + incoming_timestamp, incoming_timestamp_valid = ( + _message_exact_timestamp_details(msg) + ) + retained_native_image_row = ( + row_id_fast_path_allowed + and sidecar_row_id_counts.get(row_id, 0) == 1 + and state_row_id_counts.get(row_id, 0) == 1 + and existing_timestamp_valid + and incoming_timestamp_valid + and existing_timestamp is not None + and existing_timestamp == incoming_timestamp + and str(msg.get("role") or "").lower() == "user" + ) + if _state_row_is_truncated( + msg, + key, + content_key, + timestamp, + state_replay_idx >= len(sidecar_visible_sequence), + retained_native_image_row=retained_native_image_row, ): + continue + if row_id_fast_path_allowed: existing_api_content = _session_message_api_content_key(existing) incoming_api_content = _session_message_api_content_key(msg) if ( @@ -11620,7 +11735,8 @@ def _merge_session_messages_append_only_impl( and existing_api_content != incoming_api_content ): # Row identity does not establish which provider payload is - # newer. Fall through so both conflicting versions survive. + # newer. A unique exact row match proves this row survived + # truncation, so preserve both versions for model context. pass else: if existing_api_content is None and incoming_api_content is not None: @@ -11743,83 +11859,9 @@ def _merge_session_messages_append_only_impl( _copy_api_content_sidecar(existing, msg) _merge_session_display_metadata(existing, msg) continue - # Skip rows ABOVE the watermark only while the sidecar has NOT advanced - # past the watermark. Because Session.save() no longer auto-clears the - # watermark, an unconditional `timestamp > watermark` skip would become - # permanent and silently drop legitimate future state.db-only recovery - # rows once the session moves forward past the edit boundary. Once the - # sidecar's own max timestamp is beyond the watermark (the session has - # advanced), allow state rows newer than the sidecar tail to merge. - # - # The sidecar's max timestamp can also EQUAL the watermark when the new - # post-edit USER turn has been checkpointed into the sidecar (its - # timestamp == the advanced watermark) but its ASSISTANT reply exists - # only in state.db (recovery before the sidecar tail advances). In that - # state truncation_boundary < watermark proves the session is genuinely - # advanced, so the post-watermark state-only reply is legitimate - # post-edit content and must merge through (not be dropped as a replaced - # tail). The conservative skip still applies for boundary is None and - # boundary == watermark (not-advanced / legacy). - # - # CRITICAL: the boundary-advanced signal may only bypass the skip AFTER - # state replay has consumed the sidecar's visible checkpoint - # (state_replay_idx >= len(sidecar_visible_sequence)). A deleted suffix - # row with ts > watermark that appears in state.db BEFORE the edited - # checkpoint must still be skipped — otherwise the advanced signal would - # resurrect it. The sidecar-max-timestamp signal needs no such gate (a - # sidecar tail beyond the watermark is itself proof the checkpoint has - # advanced). checkpoint_consumed = state_replay_idx >= len(sidecar_visible_sequence) - sidecar_advanced_past_watermark = ( - watermark_timestamp is not None - and ( - (max_sidecar_timestamp is not None - and max_sidecar_timestamp > watermark_timestamp) - or (watermark_advanced_by_boundary and checkpoint_consumed) - ) - ) - if ( - watermark_timestamp is not None - and timestamp is not None - and timestamp > watermark_timestamp - and key not in seen_message_keys - and ( - not sidecar_advanced_past_watermark - or (max_sidecar_timestamp is not None and timestamp <= max_sidecar_timestamp) - ) - ): - continue - # When a truncation watermark is active, state.db may contain original - # messages that were replaced by Edit (old content with old timestamp). - # The timestamp-based filter above catches messages AFTER the watermark, - # but messages BEFORE it (like the original pre-edit content) slip through. - # If a state.db message's content is not present in the sidecar and its - # timestamp is before the watermark, it's a replaced/stale row — skip it. - if ( - watermark_timestamp is not None - and timestamp is not None - and timestamp < watermark_timestamp - and key not in seen_message_keys - and content_key not in seen_content_keys - ): - continue - # Same-second edit: if timestamp equals the watermark and the message - # content is not in the sidecar, it's a replaced message edited at the - # same second — skip it. The edited version (same timestamp, different - # content) is in the sidecar and survives this check. - # - # Only apply the same-second guard to user messages. An assistant reply - # (or tool message) at the same second as the watermark is a legitimate - # post-edit recovery row — the sidecar holds only the edited user - # checkpoint, so the assistant reply's content won't be in it and would - # be silently dropped without this role guard. - if ( - watermark_timestamp is not None - and timestamp is not None - and timestamp == watermark_timestamp - and key not in seen_message_keys - and content_key not in seen_content_keys - and str(msg.get("role", "")).lower() == "user" + if _state_row_is_truncated( + msg, key, content_key, timestamp, checkpoint_consumed, ): continue # Check for true duplicates using full-precision timestamp (#3346). diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index b6301765..919ed855 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -199,8 +199,11 @@ and 5; it does not mark every run-state boundary implemented. share the existing sidecar bubble only when its valid durable row ID, exact timestamp, and exact visible user content match; keep the sidecar-owned row and its display metadata. Distinct row IDs, ambiguous or invalid identities, - and different visible user text remain separate. Fill a missing payload from - the other copy; repeated reconciliation must remain bounded and idempotent. + and different visible user text remain separate only while eligible under + the existing edit/undo truncation watermark and checkpoint-order rules; + removed rows must not reappear in display or model replay. Fill a missing + payload from the other copy; repeated reconciliation must remain bounded + and idempotent. Agent state.db alone cannot restore the original attachment if the WebUI sidecar is lost. Visible interim assistant progress must remain visible timeline content; a diff --git a/tests/test_native_image_turn_display_context.py b/tests/test_native_image_turn_display_context.py index cf3de3a0..ae9205b1 100644 --- a/tests/test_native_image_turn_display_context.py +++ b/tests/test_native_image_turn_display_context.py @@ -338,6 +338,221 @@ def test_get_session_projects_marked_payload_conflict_in_full_and_limited_paths( } +@pytest.mark.parametrize("msg_limit", [None, 5]) +@pytest.mark.parametrize("removed_payload_matches_retained", [False, True]) +def test_truncation_watermark_keeps_proven_retained_image_row_only( + monkeypatch, tmp_path, msg_limit, removed_payload_matches_retained, +): + import api.config + import api.models as models + import api.session_ops + from api import routes + + session_id = f"native-image-truncated-route-{msg_limit}" + timestamp = 860.0 + retained_tail_timestamp = 880.0 + sidecar_payload = "SIDECAR-IMAGE-PAYLOAD-A" + state_payload = "STATE-DB-IMAGE-PAYLOAD-B" + removed_payload = ( + sidecar_payload if removed_payload_matches_retained + else "STATE-DB-REMOVED-IMAGE-PAYLOAD" + ) + seed, identity, _ = _settle_image_turn( + session_id=session_id, + timestamp=timestamp, + agent_row_id=41, + ) + context_user = next( + message for message in seed.context_messages + if message.get("_active_turn_token") == identity["token"] + ) + mirror = _durable_agent_content(context_user["content"]) + older_rows = [ + {"role": "user", "content": "Earlier prompt", "timestamp": 100.0}, + {"role": "assistant", "content": "Earlier response", "timestamp": 101.0}, + ] + seed.messages[:0] = [dict(row) for row in older_rows] + seed.context_messages[:0] = [dict(row) for row in older_rows] + sidecar_attachments = [{ + "name": "sidecar-owned.png", + "mime": "image/png", + "is_image": True, + }] + retained_sidecar_row = { + "role": "user", + "content": mirror, + "timestamp": timestamp, + "_state_db_row_id": 42, + "api_content": sidecar_payload, + "attachments": sidecar_attachments, + } + retained_tail_row = { + "role": "assistant", + "content": "Retained after Undo", + "timestamp": retained_tail_timestamp, + } + seed.messages.extend([retained_sidecar_row, retained_tail_row]) + seed.context_messages.extend([retained_sidecar_row, retained_tail_row]) + + session_dir = tmp_path / "sessions" + session_dir.mkdir() + db_path = tmp_path / "state.db" + with sqlite3.connect(db_path) as conn: + conn.execute( + "CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, " + "content TEXT, timestamp REAL, active INTEGER DEFAULT 1, api_content TEXT)" + ) + conn.executemany( + "INSERT INTO messages (id, session_id, role, content, timestamp, api_content) " + "VALUES (?, ?, ?, ?, ?, ?)", + [ + (42, session_id, "user", mirror, timestamp, state_payload), + (43, session_id, "user", mirror, timestamp, removed_payload), + (44, session_id, "user", mirror, retained_tail_timestamp, "SAME-SECOND-REMOVED-PAYLOAD"), + ], + ) + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") + monkeypatch.setattr(routes, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "_active_state_db_path", lambda: db_path) + session = models.Session( + session_id=session_id, + workspace="/fixture", + model="fixture-model", + context_length=128_000, + messages=seed.messages, + context_messages=seed.context_messages, + truncation_watermark=retained_tail_timestamp, + truncation_boundary=retained_tail_timestamp, + source_tag="webui", + session_source="webui", + ) + session.save(skip_index=True) + with models.LOCK: + models.SESSIONS.pop(session_id, None) + state_rows = models.get_state_db_session_messages(session_id) + marked_state_rows = models._suppress_native_image_display_mirrors( + session, + state_rows, + ) + assert all( + message["_webui_unmatched_native_image_mirror"] is True + for message in marked_state_rows[:2] + ) + + monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *_: True) + monkeypatch.setattr(routes, "_active_stream_ids", lambda: set()) + monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda *_: {}) + monkeypatch.setattr(routes, "find_run_summary", lambda *_: None) + monkeypatch.setattr(api.config, "load_settings", lambda: {"api_redact_enabled": False}) + monkeypatch.setattr(api.session_ops, "regeneration_state", lambda _session: ([], [])) + monkeypatch.setattr(api.session_ops, "regeneration_authority", lambda *_args, **_kwargs: None) + response = {} + monkeypatch.setattr( + routes, + "j", + lambda _handler, payload, status=200, **_kwargs: ( + response.update(payload=payload, status=status) or payload + ), + ) + query = f"session_id={session_id}&resolve_model=0" + if msg_limit is not None: + query += f"&msg_limit={msg_limit}" + routes._handle_session_get(None, SimpleNamespace(path="/api/session", query=query)) + + assert response["status"] == 200 + public_session = response["payload"]["session"] + public_messages = public_session["messages"] + if msg_limit is not None: + assert len(public_messages) == msg_limit + assert public_session["message_count"] > len(public_messages) + public_mirrors = [ + message for message in public_messages + if message.get("content") == mirror + ] + assert len(public_mirrors) == 1 + assert public_mirrors[0]["attachments"] == sidecar_attachments + assert any( + message.get("content") == "Retained after Undo" + for message in public_messages + ) + assert next( + message for message in public_messages + if message.get("content") == "Describe this image" + )["attachments"][0]["name"] == "sample.png" + + first_public = None + first_context = None + for _ in range(3): + display = models.reconciled_state_db_messages_for_session( + session, + state_messages=state_rows, + ) + context = models.reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=state_rows, + ) + display_row_42 = [ + message for message in display + if message.get("_state_db_row_id") == 42 + ] + context_row_42 = [ + message for message in context + if message.get("_state_db_row_id") == 42 + ] + assert len(display_row_42) == 1 + assert display_row_42[0]["api_content"] == sidecar_payload + assert display_row_42[0]["attachments"] == sidecar_attachments + assert len(context_row_42) == 2 + assert {message["api_content"] for message in context_row_42} == { + sidecar_payload, state_payload, + } + assert not any( + message.get("_state_db_row_id") in (43, 44) + for message in (*display, *context) + ) + assert any( + message.get("_active_turn_token") == identity["token"] + for message in context + ) + public = public_session_projection({"messages": display})["messages"] + replay = _sanitize_messages_for_agent(context) + assert sum(message.get("content") == mirror for message in public) == 1 + replay_row_42 = [ + message for message in replay + if message.get("content") == mirror + ] + assert len(replay_row_42) == 2 + assert {message["api_content"] for message in replay_row_42} == { + sidecar_payload, state_payload, + } + if first_public is None: + first_public = public + first_context = replay + else: + assert public == first_public + assert replay == first_context + + session.truncation_boundary = timestamp - 10 + advanced_state_copy = { + "role": "user", + "content": mirror, + "timestamp": retained_tail_timestamp + 5, + "_state_db_row_id": 42, + "api_content": state_payload, + } + advanced_context = models.reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=[advanced_state_copy], + ) + assert not any( + message.get("timestamp") == retained_tail_timestamp + 5 + for message in advanced_context + ) + + def test_get_session_projects_parent_only_payload_conflict_without_losing_parent_rows( monkeypatch, tmp_path, ): From 315ea7d3a8fb5c029320e2963092c9e6fc6e42ee Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 24 Sep 2026 10:47:47 +0000 Subject: [PATCH 16/16] fix(models): let a marked image row consume its replay slot The row-id fast path for a marked native-image mirror row continued without advancing state_replay_idx, unlike the ordinary and multimodal- mirror replay paths. The edited checkpoint then never read as consumed, so a rerun reply that exists only in state.db was truncated. Master (no marker branch) keeps that reply. Advance the replay index when the resolved sidecar row is the next expected visible row, the same guard the mirror branch uses. Adds a regression that fails without the fix: sidecar [u1,a1,img#42,a_img,uE], state [.., marked img#42, a_img, u_old, a_old, uE, aE], watermark 900, boundary 861 -> aE survives, u_old/a_old stay out. Found by the release-manager advisor gate on #7754. --- api/models.py | 10 +++ .../test_7754_image_row_replay_checkpoint.py | 65 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/test_7754_image_row_replay_checkpoint.py diff --git a/api/models.py b/api/models.py index 98cf40a5..68f60cc8 100644 --- a/api/models.py +++ b/api/models.py @@ -11727,6 +11727,16 @@ def _merge_session_messages_append_only_impl( ): continue if row_id_fast_path_allowed: + # This state row replays the sidecar row it resolved to, so it + # consumes that position in the replay sequence exactly like the + # ordinary and multimodal-mirror paths do. Without this the + # checkpoint never reads as consumed and a later state-only + # reply after an edited checkpoint is truncated. + if ( + state_replay_idx < len(sidecar_visible_messages) + and sidecar_visible_messages[state_replay_idx] is existing + ): + state_replay_idx += 1 existing_api_content = _session_message_api_content_key(existing) incoming_api_content = _session_message_api_content_key(msg) if ( diff --git a/tests/test_7754_image_row_replay_checkpoint.py b/tests/test_7754_image_row_replay_checkpoint.py new file mode 100644 index 00000000..066fe269 --- /dev/null +++ b/tests/test_7754_image_row_replay_checkpoint.py @@ -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