fix(session): preserve subsecond message timestamp order
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
29
tests/test_message_timestamp_stamping.py
Normal file
29
tests/test_message_timestamp_stamping.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user