Release v0.51.266 — Release IH (stage-r16) (#3641)

## Release v0.51.266 — Release IH (stage-r16)

One agent-authored APPROVED fix + two un-held streaming/SSE fixes.

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3635 (#3637) | @nesquena-hermes (nesquena APPROVED) | Composer profile chip reads `S.activeProfile` again — a #3331 regression keyed it on the loaded session's profile, so opening a cross-profile session made the chip disagree with the dropdown checkmark and misrepresent where the next message routes. #3331's project/session-op scoping is unaffected. |
| #3587 (#3605) | @rodboev | Reasoning persists to the correct intermediate assistant message in multi-turn tool flows. The index only advanced in `on_interim_assistant` (suppressed for contentless tool-call messages) → post-tool reasoning was mis-attributed; it now also advances at the `on_tool` boundary, guarded against over-increment. **(un-held — finding resolved)** |
| #2660 (#3558) | @franksong2702 | Session-event SSE no longer wakes every tab across profiles and never drops a relevant refresh — profile attached when known, root/`default` aliases stay unscoped, and the `maxsize=1` queue falls back to unscoped refresh-all on a profile-mismatch coalesce. **(un-held — both findings resolved)** |

### Gate
- Full pytest suite: **7770 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — chip matches dropdown/routing (no #3331 scoping regression), reasoning-index advance composes with the agent's tool/interim callback ordering, session-events coalesce safely with no dropped refresh and no profile data leak (`/api/sessions` still server-side filtered).

Co-authored-by: nesquena <nesquena@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-05 00:44:03 -07:00
1 parent cab9b0672c
commit 9b933e2c83
20 files changed
+801 -63

No files matched your search

+54 -10
View File
@@ -4782,7 +4782,12 @@ def _run_agent_streaming(
try:
_token_sent = False # tracks whether any streamed tokens were sent
_self_healed = False # (#1401) prevents infinite self-heal retries
_reasoning_text = '' # accumulates reasoning/thinking trace for persistence
# Per-message reasoning: dict maps assistant-message index → accumulated text
# (#3587) replaces the flat _reasoning_text string so each intermediate
# assistant turn (before tool calls) keeps its own reasoning segment.
_reasoning_segments: dict = {}
_current_reasoning_idx = 0
_tool_boundary_advanced = False
_live_tool_calls = [] # tool progress fallback when final messages omit tool IDs
# Throttle: emit metering events at most every 100 ms so the per-message
@@ -4832,9 +4837,10 @@ def _run_agent_streaming(
_emit_metering()
def on_reasoning(text):
nonlocal _reasoning_text
nonlocal _reasoning_segments, _current_reasoning_idx, _tool_boundary_advanced
if text is None:
return
_tool_boundary_advanced = False
reasoning_delta = str(text)
# Some runtimes mirror user-visible progress text through the
# reasoning channel after it already streamed as normal assistant
@@ -4842,8 +4848,13 @@ def _run_agent_streaming(
# same sentence again inside a Thinking card.
if _is_visible_output_echo(reasoning_delta):
return
_reasoning_text += reasoning_delta
# Mirror to shared dict so cancel_stream() can persist it (#1361 §A)
# Accumulate into the current message's segment (#3587)
_reasoning_segments[_current_reasoning_idx] = (
_reasoning_segments.get(_current_reasoning_idx, '') + reasoning_delta
)
# Mirror full concatenation to shared dict so cancel_stream() can persist
# it (#1361 §A). Cancel only creates one partial message, so the flat
# concatenation is correct there.
if stream_id in STREAM_REASONING_TEXT:
STREAM_REASONING_TEXT[stream_id] += reasoning_delta
put('reasoning', {'text': reasoning_delta})
@@ -4853,6 +4864,12 @@ def _run_agent_streaming(
_emit_metering()
def on_interim_assistant(text, **cb_kwargs):
nonlocal _current_reasoning_idx
# Advance the per-message reasoning index unconditionally (#3587):
# even if this callback fires with empty text, a new assistant
# segment is starting and subsequent reasoning must be attributed
# to the next message.
_current_reasoning_idx += 1
if text is None:
return
visible = str(text).strip()
@@ -4911,7 +4928,7 @@ def _run_agent_streaming(
return True
def on_tool(*cb_args, **cb_kwargs):
nonlocal _reasoning_text
nonlocal _reasoning_segments, _current_reasoning_idx, _tool_boundary_advanced
event_type = None
name = None
preview = None
@@ -4937,8 +4954,11 @@ def _run_agent_streaming(
# Suppress those echoes like the dedicated reasoning callback.
if _is_visible_output_echo(reason_delta):
return
_reasoning_text += reason_delta
# Mirror to shared dict so cancel_stream() can persist it (#1361 §A)
# Accumulate into the current message's segment (#3587)
_reasoning_segments[_current_reasoning_idx] = (
_reasoning_segments.get(_current_reasoning_idx, '') + reason_delta
)
# Mirror full concatenation to shared dict (#1361 §A)
if stream_id in STREAM_REASONING_TEXT:
STREAM_REASONING_TEXT[stream_id] += reason_delta
put('reasoning', {'text': reason_delta})
@@ -4947,6 +4967,15 @@ def _run_agent_streaming(
_emit_metering()
return
# (#3587) Advance reasoning index at tool-call boundaries.
# on_interim_assistant is suppressed for contentless tool-call
# messages (run_agent.py:3834), so the index never advances
# there. The first tool.started event after reasoning indicates
# a new assistant message boundary.
if not _tool_boundary_advanced and _current_reasoning_idx in _reasoning_segments:
_current_reasoning_idx += 1
_tool_boundary_advanced = True
args_snap = _tool_args_snapshot(args)
# Modern Hermes Agent builds can call both tool_progress_callback
@@ -6329,11 +6358,27 @@ def _run_agent_streaming(
# persisted session file 30-50% and bypassing the thinking card. The
# split is leading-only/single-block so mid-body literal tags (e.g. in
# a fenced code block) stay visible content.
#
# #3587: use per-message segments so intermediate assistant turns
# (before tool calls) each receive their own reasoning trace rather
# than all reasoning being written only to the last assistant message.
# Scope the walk to this turn's newly-appended assistant messages
# to prevent cross-turn reasoning clobber (multi-turn off-by-N).
if s.messages:
for _rm in reversed(s.messages):
_prev_asst = sum(
1 for m in (_previous_messages or [])
if isinstance(m, dict) and m.get('role') == 'assistant'
)
_asst_count = 0
for _rm in s.messages:
if not (isinstance(_rm, dict) and _rm.get('role') == 'assistant'):
continue
_existing_reasoning = _reasoning_text or _rm.get('reasoning') or ''
_turn_idx = _asst_count
_asst_count += 1
if _turn_idx < _prev_asst:
continue # prior-turn message — never touch its reasoning
_seg_reasoning = _reasoning_segments.get(_turn_idx - _prev_asst, '')
_existing_reasoning = _seg_reasoning or _rm.get('reasoning') or ''
_content = _rm.get('content')
if isinstance(_content, str) and _content:
_new_content, _merged_reasoning = _split_thinking_from_content(
@@ -6344,7 +6389,6 @@ def _run_agent_streaming(
_rm['reasoning'] = _merged_reasoning
elif _existing_reasoning:
_rm['reasoning'] = _existing_reasoning
break
try:
_turn_duration_seconds = max(0.0, time.time() - float(_turn_started_at))
except Exception: