Some checks failed
Release & Docker / release (push) Has been cancelled
## 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>
293 lines
12 KiB
Python
293 lines
12 KiB
Python
"""Regression tests for issue #3587: intermediate assistant message reasoning lost.
|
|
|
|
During multi-turn streaming (assistant reasons → calls tool → reasons again →
|
|
responds), the flat _reasoning_text accumulator was written only to the LAST
|
|
assistant message on settlement. Intermediate assistant messages (before tool
|
|
calls) permanently lost their reasoning traces.
|
|
|
|
Fix: replace the flat accumulator with a per-message dict (_reasoning_segments),
|
|
track assistant message transitions via on_interim_assistant, and iterate forward
|
|
through s.messages on settlement so each assistant message receives its own
|
|
reasoning segment.
|
|
"""
|
|
|
|
import pathlib
|
|
import re
|
|
|
|
REPO = pathlib.Path(__file__).parent.parent
|
|
|
|
|
|
def read(rel):
|
|
return (REPO / rel).read_text(encoding='utf-8')
|
|
|
|
|
|
# ── 1. Flat accumulator is replaced ──────────────────────────────────────────
|
|
|
|
|
|
class TestAccumulatorReplaced:
|
|
"""The flat string accumulator must be replaced by a per-message dict."""
|
|
|
|
def test_bare_string_declaration_removed(self):
|
|
src = read('api/streaming.py')
|
|
# The old declaration was exactly: _reasoning_text = ''
|
|
# It must no longer exist as a bare string assignment (the comment that
|
|
# mentions it by name is allowed, but the assignment itself must be gone).
|
|
assert "_reasoning_text = ''" not in src, (
|
|
"_reasoning_text = '' bare declaration must be replaced by the "
|
|
"per-message _reasoning_segments dict (#3587)"
|
|
)
|
|
|
|
def test_segments_dict_declared(self):
|
|
src = read('api/streaming.py')
|
|
assert '_reasoning_segments' in src, (
|
|
"_reasoning_segments dict must be declared in api/streaming.py"
|
|
)
|
|
assert '_current_reasoning_idx' in src, (
|
|
"_current_reasoning_idx counter must be declared in api/streaming.py"
|
|
)
|
|
|
|
def test_segments_dict_is_dict_type(self):
|
|
src = read('api/streaming.py')
|
|
# Declaration must be an empty dict, not a string
|
|
assert re.search(r'_reasoning_segments\s*(?::\s*dict\s*)?\=\s*\{\}', src), (
|
|
"_reasoning_segments must be initialized as an empty dict"
|
|
)
|
|
|
|
|
|
# ── 2. on_reasoning indexes into per-message dict ────────────────────────────
|
|
|
|
|
|
class TestOnReasoningPerMessageIndexing:
|
|
"""The on_reasoning callback must index into _reasoning_segments using
|
|
_current_reasoning_idx instead of appending to a flat string."""
|
|
|
|
def _on_reasoning_body(self):
|
|
src = read('api/streaming.py')
|
|
m = re.search(
|
|
r'def on_reasoning\(text\):\s*\n(.*?)(?=\n\s{12}def |\n\s{8}def )',
|
|
src, re.DOTALL,
|
|
)
|
|
assert m, "on_reasoning function not found in api/streaming.py"
|
|
return m.group(1)
|
|
|
|
def test_on_reasoning_uses_segments_not_flat_string(self):
|
|
body = self._on_reasoning_body()
|
|
assert '_reasoning_segments' in body, (
|
|
"on_reasoning must accumulate into _reasoning_segments, not a flat string"
|
|
)
|
|
assert "_reasoning_text +=" not in body, (
|
|
"on_reasoning must not use the old flat _reasoning_text += pattern"
|
|
)
|
|
|
|
def test_on_reasoning_indexes_by_current_idx(self):
|
|
body = self._on_reasoning_body()
|
|
assert '_current_reasoning_idx' in body, (
|
|
"on_reasoning must reference _current_reasoning_idx to attribute "
|
|
"reasoning deltas to the correct assistant message"
|
|
)
|
|
|
|
def test_stream_reasoning_text_mirror_still_present(self):
|
|
"""cancel_stream() uses STREAM_REASONING_TEXT for its own partial-message
|
|
persist path; this mirror must remain even after the per-message fix."""
|
|
body = self._on_reasoning_body()
|
|
assert 'STREAM_REASONING_TEXT' in body, (
|
|
"on_reasoning must still mirror to STREAM_REASONING_TEXT so "
|
|
"cancel_stream() can persist reasoning on mid-stream cancellation"
|
|
)
|
|
|
|
|
|
# ── 3. on_interim_assistant advances the index ───────────────────────────────
|
|
|
|
|
|
class TestInterimAssistantAdvancesIndex:
|
|
"""on_interim_assistant fires when a new assistant segment starts after tool
|
|
results. It must increment _current_reasoning_idx so subsequent reasoning
|
|
deltas are attributed to the next assistant message."""
|
|
|
|
def _interim_body(self):
|
|
src = read('api/streaming.py')
|
|
m = re.search(
|
|
r'def on_interim_assistant\(text.*?\):\s*\n(.*?)(?=\n\s{12}def |\n\s{8}def )',
|
|
src, re.DOTALL,
|
|
)
|
|
assert m, "on_interim_assistant function not found in api/streaming.py"
|
|
return m.group(1)
|
|
|
|
def test_interim_assistant_increments_idx(self):
|
|
body = self._interim_body()
|
|
assert '_current_reasoning_idx' in body, (
|
|
"on_interim_assistant must increment _current_reasoning_idx to "
|
|
"advance the per-message reasoning segment pointer (#3587)"
|
|
)
|
|
assert re.search(r'_current_reasoning_idx\s*\+=\s*1', body), (
|
|
"on_interim_assistant must use += 1 to advance the segment index"
|
|
)
|
|
|
|
|
|
# ── 4. Settlement loop iterates forward, not reversed+break ──────────────────
|
|
|
|
|
|
class TestSettlementLoopForward:
|
|
"""The settlement loop must iterate forward through s.messages so each
|
|
assistant message can be matched to its own reasoning segment by index.
|
|
The old reversed()+break pattern only wrote reasoning to the last message."""
|
|
|
|
def _settlement_block(self):
|
|
"""Extract the reasoning-persistence settlement block from streaming.py."""
|
|
src = read('api/streaming.py')
|
|
# Anchor on the comment that appears just before the settlement block
|
|
start = src.find('# #3587: use per-message segments')
|
|
assert start >= 0, (
|
|
"Settlement block comment '#3587: use per-message segments' not found; "
|
|
"the block may have been moved or the comment changed"
|
|
)
|
|
# Grab enough context to cover the loop
|
|
return src[start:start + 1500]
|
|
|
|
def test_settlement_does_not_reverse_iterate_with_break(self):
|
|
block = self._settlement_block()
|
|
# The old pattern was: for _rm in reversed(s.messages): ... break
|
|
# Both conditions must be gone from the settlement block.
|
|
has_reversed_break = (
|
|
'reversed(s.messages)' in block and
|
|
re.search(r'\bbreak\b', block)
|
|
)
|
|
assert not has_reversed_break, (
|
|
"Settlement loop must not use reversed(s.messages)+break; "
|
|
"that pattern writes reasoning only to the last assistant message"
|
|
)
|
|
|
|
def test_settlement_iterates_forward_with_counter(self):
|
|
block = self._settlement_block()
|
|
# Forward iteration with an assistant counter
|
|
assert 'for _rm in s.messages' in block, (
|
|
"Settlement loop must iterate forward (for _rm in s.messages) "
|
|
"to match each assistant message to its reasoning segment"
|
|
)
|
|
assert '_asst_count' in block, (
|
|
"Settlement loop must use an assistant message counter (_asst_count) "
|
|
"to index into _reasoning_segments"
|
|
)
|
|
|
|
def test_settlement_reads_from_segments_dict(self):
|
|
block = self._settlement_block()
|
|
assert '_reasoning_segments.get' in block, (
|
|
"Settlement loop must read from _reasoning_segments.get(idx) "
|
|
"to retrieve the per-message reasoning trace"
|
|
)
|
|
|
|
|
|
# ── 5. Multi-turn offset prevents cross-turn reasoning clobber ──────────────
|
|
|
|
|
|
class TestMultiTurnOffset:
|
|
"""The settlement loop must skip prior-turn assistant messages so that
|
|
_reasoning_segments (indexed from 0 for this turn only) doesn't overwrite
|
|
reasoning stored on earlier turns."""
|
|
|
|
def _settlement_block(self):
|
|
src = read('api/streaming.py')
|
|
start = src.find('# #3587: use per-message segments')
|
|
assert start >= 0, 'Settlement block not found'
|
|
return src[start:start + 1500]
|
|
|
|
def test_settlement_computes_prev_asst_offset(self):
|
|
block = self._settlement_block()
|
|
assert '_prev_asst' in block, (
|
|
"Settlement loop must compute _prev_asst (count of assistant "
|
|
"messages in _previous_messages) to offset the segment index"
|
|
)
|
|
|
|
def test_settlement_skips_prior_turn_messages(self):
|
|
block = self._settlement_block()
|
|
assert re.search(r'if\s+_turn_idx\s*<\s*_prev_asst\s*:', block), (
|
|
"Settlement loop must skip prior-turn messages with "
|
|
"if _turn_idx < _prev_asst: continue"
|
|
)
|
|
|
|
def test_segment_index_subtracts_offset(self):
|
|
block = self._settlement_block()
|
|
assert re.search(r'_turn_idx\s*-\s*_prev_asst', block), (
|
|
"Segment index must subtract _prev_asst offset so indexing "
|
|
"starts at 0 for this turn's first assistant message"
|
|
)
|
|
|
|
|
|
# ── 6. Tool-call boundary advances reasoning index ────────────────────────────
|
|
|
|
|
|
class TestToolCallBoundary:
|
|
"""on_interim_assistant is suppressed for contentless tool-call assistant
|
|
messages (run_agent.py:3834 early-returns when content is empty). The
|
|
reasoning index must advance at tool-call boundaries instead, so reasoning
|
|
accumulated before a tool-call-only assistant message gets its own segment."""
|
|
|
|
def _on_tool_body(self):
|
|
src = read('api/streaming.py')
|
|
m = re.search(
|
|
r'def on_tool\(\*cb_args.*?\):\s*\n(.*?)(?=\n\s{12}def |\n\s{8}def )',
|
|
src, re.DOTALL,
|
|
)
|
|
assert m, "on_tool function not found in api/streaming.py"
|
|
return m.group(1)
|
|
|
|
def test_on_tool_advances_reasoning_idx(self):
|
|
body = self._on_tool_body()
|
|
assert '_current_reasoning_idx' in body, (
|
|
"on_tool must reference _current_reasoning_idx to advance the "
|
|
"reasoning segment at tool-call boundaries (#3587)"
|
|
)
|
|
|
|
def test_tool_boundary_guard_prevents_double_advance(self):
|
|
body = self._on_tool_body()
|
|
assert '_tool_boundary_advanced' in body, (
|
|
"on_tool must use a _tool_boundary_advanced guard so multiple "
|
|
"tool calls in one assistant message only advance the index once"
|
|
)
|
|
|
|
def test_tool_boundary_flag_declared(self):
|
|
src = read('api/streaming.py')
|
|
assert '_tool_boundary_advanced' in src, (
|
|
"_tool_boundary_advanced flag must be declared in streaming.py"
|
|
)
|
|
|
|
def test_reasoning_resets_tool_boundary_flag(self):
|
|
"""New reasoning arriving after a tool boundary must reset the guard
|
|
so the next tool-call batch can advance the index again."""
|
|
src = read('api/streaming.py')
|
|
m = re.search(
|
|
r'def on_reasoning\(text\):\s*\n(.*?)(?=\n\s{12}def |\n\s{8}def )',
|
|
src, re.DOTALL,
|
|
)
|
|
assert m, "on_reasoning function not found"
|
|
body = m.group(1)
|
|
assert '_tool_boundary_advanced' in body, (
|
|
"on_reasoning must reset _tool_boundary_advanced so the next "
|
|
"tool-call batch can advance the reasoning index"
|
|
)
|
|
|
|
|
|
# ── 7. Settlement counter increments exactly once per assistant message ────
|
|
|
|
|
|
class TestSettlementCounterSingleIncrement:
|
|
"""_asst_count must increment exactly once per assistant message in the
|
|
settlement loop. A double increment causes every message after the first
|
|
to look up a segment index that doesn't exist, silently discarding its
|
|
reasoning (the exact data-loss scenario the refactor was meant to fix)."""
|
|
|
|
def _settlement_block(self):
|
|
src = read('api/streaming.py')
|
|
start = src.find('# #3587: use per-message segments')
|
|
assert start >= 0
|
|
return src[start:start + 1500]
|
|
|
|
def test_single_increment_per_iteration(self):
|
|
block = self._settlement_block()
|
|
count = block.count('_asst_count += 1')
|
|
assert count == 1, (
|
|
f"_asst_count must be incremented exactly once per loop iteration, "
|
|
f"found {count} increments. A double increment causes segment index "
|
|
f"doubling: message N looks up segment 2*N instead of N."
|
|
)
|