diff --git a/CHANGELOG.md b/CHANGELOG.md index 3351ace3..ce8e7d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - Messaging/session display merges now preserve sidecar transcript order when the sidecar already contains at least as many rows as the mirrored state store, avoiding role/content fallback sorting when timestamp precision collapses. +- Gateway-backed turns and compacted/reconciled message batches now keep subsecond timestamp ordering instead of assigning the same integer-second timestamp to multiple transcript rows. + ## [v0.51.153] — 2026-05-28 — Release DY (stage-batch35 — 11-PR low-risk cleanup: title-language + clarify SSE + upload filename + discoverability + SSE reconnect + gateway image + docker docs) ### Changed diff --git a/api/gateway_chat.py b/api/gateway_chat.py index d3a0aad0..25818084 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -297,11 +297,16 @@ def _run_gateway_chat_streaming( s = get_session(session_id) if not _stream_writeback_is_current(s, stream_id): return - now = int(time.time()) + now = time.time() + # Preserve subsecond ordering for gateway-backed turns. Using an + # integer seconds timestamp gives the user and assistant rows the + # same sort key; later transcript merges can then fall back to + # role/content ordering instead of turn order. + assistant_ts = now + 0.000001 user_msg = {"role": "user", "content": str(msg_text or ""), "timestamp": now} if attachments: user_msg["attachments"] = list(attachments) - assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": now} + assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": assistant_ts} previous_context = list(getattr(s, "context_messages", None) or getattr(s, "messages", None) or []) s.context_messages = previous_context + [user_msg, assistant_msg] display = list(getattr(s, "messages", None) or []) diff --git a/api/streaming.py b/api/streaming.py index 669b6e55..df515511 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2982,6 +2982,22 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex return merged +def _stamp_missing_message_timestamps(messages, *, now: float | None = None) -> int: + """Stamp missing message timestamps without collapsing transcript order. + + Compacted/reconciled rows can arrive without timestamps. Assigning one + integer seconds value to the whole batch makes later timestamp-based display + merges unstable; use a subsecond sequence instead. + """ + base = time.time() if now is None else float(now) + stamped = 0 + for msg in messages or []: + if isinstance(msg, dict) and not msg.get('timestamp') and not msg.get('_ts'): + msg['timestamp'] = base + (stamped * 0.000001) + stamped += 1 + return stamped + + def _assistant_reply_added_after_current_turn(result_messages, previous_context, msg_text) -> bool: """Return True only when the just-finished turn produced assistant text.""" result_messages = list(result_messages or []) @@ -5298,11 +5314,9 @@ def _run_agent_streaming( 'usage': _live_usage_snapshot(), }) - # Stamp 'timestamp' on any messages that don't have one yet - _now = time.time() - for _m in s.messages: - if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'): - _m['timestamp'] = int(_now) + # Stamp 'timestamp' on any messages that don't have one yet, + # preserving transcript order across compacted/reconciled batches. + _stamp_missing_message_timestamps(s.messages) # Only auto-generate title when still default; preserves user renames if s.title == 'Untitled' or s.title == 'New Chat' or not s.title: s.title = title_from(s.messages, s.title) diff --git a/tests/test_message_timestamp_stamping.py b/tests/test_message_timestamp_stamping.py new file mode 100644 index 00000000..1a4b50f6 --- /dev/null +++ b/tests/test_message_timestamp_stamping.py @@ -0,0 +1,29 @@ +from api.streaming import _stamp_missing_message_timestamps + + +def test_stamp_missing_message_timestamps_uses_subsecond_sequence(): + messages = [ + {"role": "user", "content": "one"}, + {"role": "assistant", "content": "two"}, + {"role": "user", "content": "three"}, + ] + + stamped = _stamp_missing_message_timestamps(messages, now=1000.0) + + assert stamped == 3 + assert [m["timestamp"] for m in messages] == [1000.0, 1000.000001, 1000.000002] + + +def test_stamp_missing_message_timestamps_preserves_existing_timestamp_metadata(): + messages = [ + {"role": "user", "content": "old", "timestamp": 900.0}, + {"role": "assistant", "content": "synthetic", "_ts": 901.0}, + {"role": "user", "content": "new"}, + ] + + stamped = _stamp_missing_message_timestamps(messages, now=1000.0) + + assert stamped == 1 + assert messages[0]["timestamp"] == 900.0 + assert "timestamp" not in messages[1] + assert messages[2]["timestamp"] == 1000.0 diff --git a/tests/test_webui_gateway_chat_backend.py b/tests/test_webui_gateway_chat_backend.py index 29b55288..4b4fa414 100644 --- a/tests/test_webui_gateway_chat_backend.py +++ b/tests/test_webui_gateway_chat_backend.py @@ -224,6 +224,9 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke saved = models.get_session(s.session_id) assert [m["role"] for m in saved.messages] == ["user", "assistant"] assert saved.messages[-1]["content"] == "hello" + assert isinstance(saved.messages[0]["timestamp"], float) + assert isinstance(saved.messages[1]["timestamp"], float) + assert saved.messages[0]["timestamp"] < saved.messages[1]["timestamp"] assert saved.active_stream_id is None assert stream_id not in STREAMS assert captured["url"] == "http://gateway.local/v1/chat/completions"