fix(session): preserve subsecond message timestamp order

This commit is contained in:
ai-ag2026
2026-05-28 19:58:18 +02:00
parent b103f4ad68
commit 07aed6b7ff
5 changed files with 62 additions and 7 deletions

View File

@@ -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

View File

@@ -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 [])

View File

@@ -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)

View File

@@ -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

View File

@@ -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"