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