Release v0.51.279 — Release IU (stage-p3h — preserve Activity/streaming turn on mid-stream scroll #3665) (#3686)
Some checks failed
Release & Docker / release (push) Has been cancelled

* fix(streaming): preserve Activity + streaming turn when loading earlier messages mid-stream (#3665, #3346)

Co-authored-by: mysoul12138 <839465496@qq.com>

* docs(changelog): v0.51.279 — Release IU (stage-p3h)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: mysoul12138 <839465496@qq.com>
This commit is contained in:
nesquena-hermes
2026-06-05 14:45:31 -07:00
committed by GitHub
parent 8f89b4f825
commit b5caf83ff9
10 changed files with 266 additions and 30 deletions

View File

@@ -3953,6 +3953,14 @@ def _session_message_merge_key(msg: dict):
message_identity = msg.get("id") or msg.get("message_id")
if message_identity:
return ("message_id", str(message_identity))
# Include tool_calls so assistant messages that invoke different tools
# (but share identical empty content and same-second timestamp) are not
# collapsed by the merge-key guard at line ~4216. Without this,
# all tool-calling messages map to the same legacy key and the
# timestamp<=max_sidecar_timestamp blanket-skip at line ~4218 drops
# every state.db tool-call after the first one registered by the sidecar.
_tc = msg.get("tool_calls")
_tc_key = json.dumps(_tc, sort_keys=True, default=str) if _tc else ""
return (
"legacy",
str(msg.get("role") or ""),
@@ -3960,6 +3968,7 @@ def _session_message_merge_key(msg: dict):
_normalized_message_timestamp_for_key(msg.get("timestamp")),
str(msg.get("tool_call_id") or ""),
str(msg.get("tool_name") or msg.get("name") or ""),
_tc_key,
)
@@ -3976,6 +3985,11 @@ def _session_message_dedup_key(msg: dict):
message_identity = msg.get("id") or msg.get("message_id")
if message_identity:
return ("message_id", str(message_identity))
# Include tool_calls in the key so assistant messages that carry
# different tool invocations (but identical empty content/timestamp)
# are never collapsed into one. (#3346 regression)
_tc = msg.get("tool_calls")
_tc_key = json.dumps(_tc, sort_keys=True, default=str) if _tc else ""
return (
"legacy",
str(msg.get("role") or ""),
@@ -3983,6 +3997,7 @@ def _session_message_dedup_key(msg: dict):
str(msg.get("timestamp") or ""),
str(msg.get("tool_call_id") or ""),
str(msg.get("tool_name") or msg.get("name") or ""),
_tc_key,
)
@@ -4010,9 +4025,16 @@ def _session_message_content_key(msg: dict):
def _session_message_visible_key(msg: dict):
if not isinstance(msg, dict):
return ("non_dict", repr(msg))
# Include tool_calls so assistant messages that invoke different tools
# (but share identical empty content) are not collapsed by sidecar
# prefix matching. Without this, all tool-calling messages map to
# ("assistant", "") and the merge treats state.db rows as replays.
_tc = msg.get("tool_calls")
_tc_key = json.dumps(_tc, sort_keys=True, default=str) if _tc else ""
return (
str(msg.get("role") or ""),
_normalized_session_message_content(msg),
_tc_key,
)
@@ -4021,8 +4043,9 @@ def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict:
loose_by_key = {}
for key in visible_keys:
try:
role, content = key
except (TypeError, ValueError):
role = key[0]
content = key[1]
except (TypeError, IndexError):
continue
if not content:
continue
@@ -4034,24 +4057,27 @@ def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict:
def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lookup: dict | None = None):
if visible_key in visible_keys:
return visible_key
role, content = visible_key
role = visible_key[0]
content = visible_key[1] if len(visible_key) > 1 else ""
if not content:
return None
if lookup is None:
lookup = _build_visible_duplicate_lookup(visible_keys)
loose_content = None
for existing_role, existing_content in lookup.get("by_role", {}).get(role, []):
for existing_key in lookup.get("by_role", {}).get(role, []):
existing_role = existing_key[0]
existing_content = existing_key[1] if len(existing_key) > 1 else ""
if role != existing_role or not existing_content:
continue
if content in existing_content or existing_content in content:
return (existing_role, existing_content)
return existing_key
if loose_content is None:
loose_content = _loose_session_message_content(content)
loose_existing = lookup.get("loose_by_key", {}).get((existing_role, existing_content), "")
loose_existing = lookup.get("loose_by_key", {}).get(existing_key, "")
if loose_content and loose_existing and (
loose_content in loose_existing or loose_existing in loose_content
):
return (existing_role, existing_content)
return existing_key
return None
@@ -4253,7 +4279,13 @@ def merge_session_messages_append_only(
if key in seen_message_keys and key[0] == "message_id":
continue
if not (isinstance(key, tuple) and key[:1] == ("message_id",)):
continue
# Legacy key within sidecar timestamp range — only skip if
# this exact merge_key was already registered by the sidecar.
# Different tool_calls produce different merge_keys even with
# identical content/timestamp, so an unchecked continue here
# would drop legitimately distinct turns. (#3346 / PR #3665)
if key in seen_message_keys:
continue
if key in seen_message_keys and key[0] == "message_id":
continue
matched_visible_key = _matching_visible_duplicate(
@@ -4283,7 +4315,20 @@ def merge_session_messages_append_only(
and timestamp is not None
and timestamp <= max_sidecar_timestamp
):
continue
# Legacy key within sidecar timestamp range. Normally skip — the
# sidecar already has this message. Exception: if the state.db
# message has tool_calls that DIFFER from the sidecar version
# (same content_key but different dedup_key because tool_calls
# differ), preserve it — distinct tool_calls must not be collapsed.
_tc = msg.get("tool_calls")
if _tc:
_ck = _session_message_content_key(msg)
if _ck in seen_content_keys and dedup_key not in seen_dedup_keys:
pass # different tool_calls from sidecar — preserve
else:
continue
else:
continue
seen_message_keys.add(key)
seen_dedup_keys.add(dedup_key)
seen_content_keys.add(_session_message_content_key(msg))