From 07aed6b7ff1adb56c3f887cbe9e957dce8cd6a8e Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Thu, 28 May 2026 19:58:18 +0200 Subject: [PATCH] fix(session): preserve subsecond message timestamp order --- CHANGELOG.md | 4 ++++ api/gateway_chat.py | 9 ++++++-- api/streaming.py | 24 ++++++++++++++++---- tests/test_message_timestamp_stamping.py | 29 ++++++++++++++++++++++++ tests/test_webui_gateway_chat_backend.py | 3 +++ 5 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 tests/test_message_timestamp_stamping.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ed7ed6..885cc83b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- 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 69e653ac..05c3aea9 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -263,11 +263,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 b4aefdcc..0ab66511 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2976,6 +2976,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 []) @@ -5284,11 +5300,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 f9c7f94d..72ebf197 100644 --- a/tests/test_webui_gateway_chat_backend.py +++ b/tests/test_webui_gateway_chat_backend.py @@ -112,6 +112,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"