Files
hermes-webui/tests/test_issue3587_intermediate_reasoning.py
nesquena-hermes 9b933e2c83
Some checks failed
Release & Docker / release (push) Has been cancelled
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>
2026-06-05 00:44:03 -07:00

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."
)