2026-04-30 23:24:29 +00:00
""" Regression tests for #1361 — Stop/Cancel discards already-streamed content.
Three distinct data - loss paths on cancel :
§ A Reasoning text accumulated in a thread - local ` _reasoning_text ` is never
visible to cancel_stream ( ) , so it ' s lost on cancel.
§ B Live tool calls accumulated in thread - local ` _live_tool_calls ` are lost
on cancel — only STREAM_PARTIAL_TEXT is captured .
§ C When the entire streamed output is reasoning ( no visible tokens ) ,
_stripped is empty after regex cleanup , so NO partial assistant message
is appended — only the * Task cancelled . * marker survives .
All three fix the same " tokens-paid-for-data-loss " class of bug .
"""
import pathlib
import queue
import re
import threading
from unittest . mock import Mock , patch
import pytest
import api . config as config
import api . models as models
import api . streaming as streaming
from api . models import Session
2026-05-17 02:49:35 +00:00
from api . run_journal import append_run_event
2026-04-30 23:24:29 +00:00
from api . streaming import cancel_stream
REPO_ROOT = pathlib . Path ( __file__ ) . parent . parent . resolve ( )
# ── Fixtures ────────────────────────────────────────────────────────────────
@pytest.fixture ( autouse = True )
def _isolate_session_dir ( tmp_path , monkeypatch ) :
""" Redirect SESSION_DIR / SESSION_INDEX_FILE to an isolated temp dir. """
session_dir = tmp_path / " sessions "
session_dir . mkdir ( )
index_file = session_dir / " _index.json "
monkeypatch . setattr ( models , " SESSION_DIR " , session_dir )
monkeypatch . setattr ( models , " SESSION_INDEX_FILE " , index_file )
models . SESSIONS . clear ( )
yield
models . SESSIONS . clear ( )
@pytest.fixture ( autouse = True )
def _isolate_stream_state ( ) :
""" Clear all shared streaming dicts before/after each test. """
config . STREAMS . clear ( )
config . CANCEL_FLAGS . clear ( )
config . AGENT_INSTANCES . clear ( )
config . STREAM_PARTIAL_TEXT . clear ( )
# New shared dicts for §A and §B
if hasattr ( config , ' STREAM_REASONING_TEXT ' ) :
config . STREAM_REASONING_TEXT . clear ( )
if hasattr ( config , ' STREAM_LIVE_TOOL_CALLS ' ) :
config . STREAM_LIVE_TOOL_CALLS . clear ( )
yield
config . STREAMS . clear ( )
config . CANCEL_FLAGS . clear ( )
config . AGENT_INSTANCES . clear ( )
config . STREAM_PARTIAL_TEXT . clear ( )
if hasattr ( config , ' STREAM_REASONING_TEXT ' ) :
config . STREAM_REASONING_TEXT . clear ( )
if hasattr ( config , ' STREAM_LIVE_TOOL_CALLS ' ) :
config . STREAM_LIVE_TOOL_CALLS . clear ( )
@pytest.fixture ( autouse = True )
def _isolate_agent_locks ( ) :
config . SESSION_AGENT_LOCKS . clear ( )
yield
config . SESSION_AGENT_LOCKS . clear ( )
def _make_session ( session_id = " cancel_sid_1361 " ,
pending_msg = " Help me debug this " ,
messages = None ) :
""" Build a session in mid-stream state. """
s = Session (
session_id = session_id ,
title = " Test Session " ,
messages = messages or [ ] ,
)
s . pending_user_message = pending_msg
s . pending_attachments = [ ]
s . pending_started_at = None
s . active_stream_id = " stream_1361 "
s . save ( )
models . SESSIONS [ session_id ] = s
return s
def _setup_cancel_state ( session_id , stream_id = " stream_1361 " ) :
""" Wire up STREAMS/CANCEL_FLAGS/AGENT_INSTANCES for cancel_stream(). """
2026-05-12 11:55:31 +00:00
models . SESSIONS [ session_id ] . active_stream_id = stream_id
2026-04-30 23:24:29 +00:00
config . STREAMS [ stream_id ] = queue . Queue ( )
config . CANCEL_FLAGS [ stream_id ] = threading . Event ( )
mock_agent = Mock ( )
mock_agent . session_id = session_id
mock_agent . interrupt = Mock ( )
config . AGENT_INSTANCES [ stream_id ] = mock_agent
return stream_id , mock_agent
# ── §A: Reasoning text lost on cancel ───────────────────────────────────────
class TestCancelPreservesReasoningText :
""" §A: _reasoning_text is thread-local and invisible to cancel_stream().
After fix : reasoning text should be persisted in a shared dict
( STREAM_REASONING_TEXT ) keyed by stream_id , and cancel_stream ( )
should append it as a ' reasoning ' field on the partial assistant message .
"""
def test_cancel_with_reasoning_only_preserves_reasoning ( self ) :
""" Cancel during reasoning phase (no visible tokens) should persist reasoning. """
sid = " test_1361_a1 "
stream_id = " stream_a1 "
s = _make_session ( session_id = sid )
_setup_cancel_state ( sid , stream_id )
# Simulate: reasoning was accumulated but no visible tokens
reasoning = " Let me think about this step by step... "
config . STREAM_PARTIAL_TEXT [ stream_id ] = " " # no visible tokens
if hasattr ( config , ' STREAM_REASONING_TEXT ' ) :
config . STREAM_REASONING_TEXT [ stream_id ] = reasoning
cancel_stream ( stream_id )
# Reload and check
s2 = models . SESSIONS [ sid ]
msgs = s2 . messages
# There should be a partial assistant message with reasoning
assistant_msgs = [ m for m in msgs if isinstance ( m , dict ) and m . get ( ' role ' ) == ' assistant ' ]
has_reasoning = any ( m . get ( ' reasoning ' ) for m in assistant_msgs )
assert has_reasoning , \
f " Expected reasoning field on partial assistant msg after cancel. Got messages: { assistant_msgs } "
def test_cancel_with_reasoning_and_partial_tokens_preserves_both ( self ) :
""" Cancel mid-stream with both reasoning and some visible tokens. """
sid = " test_1361_a2 "
stream_id = " stream_a2 "
s = _make_session ( session_id = sid )
_setup_cancel_state ( sid , stream_id )
reasoning = " Let me analyze the code... "
partial_text = " Based on my analysis, the bug is in the "
config . STREAM_PARTIAL_TEXT [ stream_id ] = partial_text
if hasattr ( config , ' STREAM_REASONING_TEXT ' ) :
config . STREAM_REASONING_TEXT [ stream_id ] = reasoning
cancel_stream ( stream_id )
s2 = models . SESSIONS [ sid ]
assistant_msgs = [ m for m in s2 . messages if isinstance ( m , dict ) and m . get ( ' role ' ) == ' assistant ' ]
# Should have partial content
partial_msgs = [ m for m in assistant_msgs if m . get ( ' _partial ' ) ]
has_content = any ( m . get ( ' content ' ) for m in partial_msgs )
assert has_content , \
f " Expected partial assistant content after cancel. Got: { partial_msgs } "
def test_cancel_without_reasoning_dict_works_as_before ( self ) :
""" If STREAM_REASONING_TEXT doesn ' t exist yet (pre-fix), cancel still works. """
sid = " test_1361_a3 "
stream_id = " stream_a3 "
s = _make_session ( session_id = sid )
_setup_cancel_state ( sid , stream_id )
config . STREAM_PARTIAL_TEXT [ stream_id ] = " Some partial text "
cancel_stream ( stream_id )
s2 = models . SESSIONS [ sid ]
msgs = s2 . messages
# Should have the cancel marker
has_cancel = any (
isinstance ( m , dict ) and m . get ( ' role ' ) == ' assistant ' and m . get ( ' _error ' )
for m in msgs
)
assert has_cancel , " Cancel marker should always be present "
# ── §B: Tool calls lost on cancel ───────────────────────────────────────────
class TestCancelPreservesToolCalls :
""" §B: _live_tool_calls is thread-local and invisible to cancel_stream().
After fix : tool calls should be persisted in a shared dict
( STREAM_LIVE_TOOL_CALLS ) keyed by stream_id , and cancel_stream ( )
should append them as tool_call entries on the partial assistant message .
"""
def test_cancel_with_tool_calls_preserves_tools ( self ) :
""" Cancel after tool execution should preserve the tool call info. """
sid = " test_1361_b1 "
stream_id = " stream_b1 "
s = _make_session ( session_id = sid )
_setup_cancel_state ( sid , stream_id )
config . STREAM_PARTIAL_TEXT [ stream_id ] = " "
if hasattr ( config , ' STREAM_LIVE_TOOL_CALLS ' ) :
config . STREAM_LIVE_TOOL_CALLS [ stream_id ] = [
{ " name " : " read_file " , " args " : { " path " : " /tmp/test.py " } , " done " : True } ,
{ " name " : " terminal " , " args " : { " command " : " ls " } , " done " : False } ,
]
cancel_stream ( stream_id )
s2 = models . SESSIONS [ sid ]
assistant_msgs = [ m for m in s2 . messages if isinstance ( m , dict ) and m . get ( ' role ' ) == ' assistant ' ]
fix(cancel-stream): rename tool_calls to _partial_tool_calls (Opus MUST-FIX)
Opus pass-2 review of v0.50.251 caught a critical regression in PR
#1375:
The cancel-partial message stored captured tool calls under the
'tool_calls' key. That key is whitelisted by _API_SAFE_MSG_KEYS so
_sanitize_messages_for_api forwarded the entries to the next-turn
LLM call. But the captured entries use the WebUI internal shape
({name, args, done, duration, is_error}) — they don't have the
OpenAI/Anthropic id + function: {name, arguments} envelope. Strict
providers (OpenAI, Anthropic, Z.AI/GLM) would 400 on the malformed
entries. Net effect: the very cancel-then-continue scenario PR
#1375 aimed to improve becomes a hard fail.
Fix:
- Rename the persisted key to '_partial_tool_calls' (underscore-
prefixed private key NOT in _API_SAFE_MSG_KEYS, so sanitize
correctly strips it).
- Update static/messages.js hasMessageToolMetadata check to also
recognize _partial_tool_calls for UI rendering.
- Update test_issue1361_cancel_data_loss.py assertion to check
_partial_tool_calls (and tool_calls as legacy fallback).
Plus 2 NIT fixes from the same Opus review:
NIT 1 (api/profiles.py:153): re.match → re.fullmatch for consistency
with other _PROFILE_ID_RE callers in the codebase. The trailing-
newline footgun ($ matches before final \n in re.match) is now
closed. Without #1373's is_dir() guard, a name like 'valid\n' would
have created a directory named 'valid\n' on Linux. Doesn't escape
<HERMES_HOME>/profiles/ via Path joining, but unintended.
NIT 2 (test_issue798.py): R19j coverage gaps — added trailing-
newline tests, length-boundary tests (64-char valid, 65-char
rejected), single-char minimum, and non-ASCII / Unicode-trick tests.
New regression test (tests/test_pr1375_partial_tool_calls_sanitize.py):
- test_partial_tool_calls_field_not_forwarded_to_llm: pins that
sanitize-for-API strips _partial_tool_calls + reasoning + does
NOT have tool_calls on a partial message
- test_legitimate_tool_calls_are_preserved_for_completed_turns:
pins that real OpenAI-shape tool_calls on completed turns survive
sanitize unchanged
Tests: 3486 passing (3484 → 3486, +2 sanitize tests).
2026-04-30 23:43:23 +00:00
has_tools = any ( m . get ( ' _partial_tool_calls ' ) or m . get ( ' tool_calls ' ) or m . get ( ' tools ' ) for m in assistant_msgs )
2026-04-30 23:24:29 +00:00
assert has_tools , \
fix(cancel-stream): rename tool_calls to _partial_tool_calls (Opus MUST-FIX)
Opus pass-2 review of v0.50.251 caught a critical regression in PR
#1375:
The cancel-partial message stored captured tool calls under the
'tool_calls' key. That key is whitelisted by _API_SAFE_MSG_KEYS so
_sanitize_messages_for_api forwarded the entries to the next-turn
LLM call. But the captured entries use the WebUI internal shape
({name, args, done, duration, is_error}) — they don't have the
OpenAI/Anthropic id + function: {name, arguments} envelope. Strict
providers (OpenAI, Anthropic, Z.AI/GLM) would 400 on the malformed
entries. Net effect: the very cancel-then-continue scenario PR
#1375 aimed to improve becomes a hard fail.
Fix:
- Rename the persisted key to '_partial_tool_calls' (underscore-
prefixed private key NOT in _API_SAFE_MSG_KEYS, so sanitize
correctly strips it).
- Update static/messages.js hasMessageToolMetadata check to also
recognize _partial_tool_calls for UI rendering.
- Update test_issue1361_cancel_data_loss.py assertion to check
_partial_tool_calls (and tool_calls as legacy fallback).
Plus 2 NIT fixes from the same Opus review:
NIT 1 (api/profiles.py:153): re.match → re.fullmatch for consistency
with other _PROFILE_ID_RE callers in the codebase. The trailing-
newline footgun ($ matches before final \n in re.match) is now
closed. Without #1373's is_dir() guard, a name like 'valid\n' would
have created a directory named 'valid\n' on Linux. Doesn't escape
<HERMES_HOME>/profiles/ via Path joining, but unintended.
NIT 2 (test_issue798.py): R19j coverage gaps — added trailing-
newline tests, length-boundary tests (64-char valid, 65-char
rejected), single-char minimum, and non-ASCII / Unicode-trick tests.
New regression test (tests/test_pr1375_partial_tool_calls_sanitize.py):
- test_partial_tool_calls_field_not_forwarded_to_llm: pins that
sanitize-for-API strips _partial_tool_calls + reasoning + does
NOT have tool_calls on a partial message
- test_legitimate_tool_calls_are_preserved_for_completed_turns:
pins that real OpenAI-shape tool_calls on completed turns survive
sanitize unchanged
Tests: 3486 passing (3484 → 3486, +2 sanitize tests).
2026-04-30 23:43:23 +00:00
f " Expected _partial_tool_calls on partial assistant msg after cancel. Got: { assistant_msgs } "
2026-04-30 23:24:29 +00:00
def test_cancel_with_tools_and_text_preserves_both ( self ) :
""" Cancel after tools + partial text should keep both. """
sid = " test_1361_b2 "
stream_id = " stream_b2 "
s = _make_session ( session_id = sid )
_setup_cancel_state ( sid , stream_id )
config . STREAM_PARTIAL_TEXT [ stream_id ] = " Here ' s what I found: "
if hasattr ( config , ' STREAM_LIVE_TOOL_CALLS ' ) :
config . STREAM_LIVE_TOOL_CALLS [ stream_id ] = [
{ " name " : " web_search " , " args " : { " query " : " test " } , " done " : True } ,
]
cancel_stream ( stream_id )
s2 = models . SESSIONS [ sid ]
assistant_msgs = [ m for m in s2 . messages if isinstance ( m , dict ) and m . get ( ' role ' ) == ' assistant ' ]
partial_msgs = [ m for m in assistant_msgs if m . get ( ' _partial ' ) ]
has_content = any ( m . get ( ' content ' ) for m in partial_msgs )
assert has_content , \
f " Expected partial content with tools after cancel. Got: { partial_msgs } "
# ── §C: Empty _stripped skips entire append ─────────────────────────────────
class TestCancelWithReasoningOnlyNoText :
""" §C: When streaming was 100 % r easoning (no visible tokens), _stripped is
empty after regex cleanup , so no partial assistant message is appended .
After fix : even when _stripped is empty , if reasoning or tool calls exist ,
a partial assistant message should be appended ( with no content , but with
reasoning and / or tool_calls fields ) .
"""
def test_reasoning_only_creates_partial_message ( self ) :
""" Cancel after reasoning-only output should still create a partial msg. """
sid = " test_1361_c1 "
stream_id = " stream_c1 "
s = _make_session ( session_id = sid )
_setup_cancel_state ( sid , stream_id )
# Only reasoning, no visible tokens at all
config . STREAM_PARTIAL_TEXT [ stream_id ] = " "
if hasattr ( config , ' STREAM_REASONING_TEXT ' ) :
config . STREAM_REASONING_TEXT [ stream_id ] = " Deep reasoning here... "
cancel_stream ( stream_id )
s2 = models . SESSIONS [ sid ]
assistant_msgs = [ m for m in s2 . messages if isinstance ( m , dict ) and m . get ( ' role ' ) == ' assistant ' ]
# Should NOT be only the cancel marker — there should be a partial msg
partial_msgs = [ m for m in assistant_msgs if m . get ( ' _partial ' ) ]
assert len ( partial_msgs ) > 0 , \
f " Expected at least one partial assistant msg for reasoning-only cancel. Got: { assistant_msgs } "
def test_tools_only_creates_partial_message ( self ) :
""" Cancel after tool-only output (no text, no reasoning) should still create a partial msg. """
sid = " test_1361_c2 "
stream_id = " stream_c2 "
s = _make_session ( session_id = sid )
_setup_cancel_state ( sid , stream_id )
config . STREAM_PARTIAL_TEXT [ stream_id ] = " "
if hasattr ( config , ' STREAM_LIVE_TOOL_CALLS ' ) :
config . STREAM_LIVE_TOOL_CALLS [ stream_id ] = [
{ " name " : " read_file " , " args " : { " path " : " /tmp/x " } , " done " : True } ,
]
cancel_stream ( stream_id )
s2 = models . SESSIONS [ sid ]
assistant_msgs = [ m for m in s2 . messages if isinstance ( m , dict ) and m . get ( ' role ' ) == ' assistant ' ]
partial_msgs = [ m for m in assistant_msgs if m . get ( ' _partial ' ) ]
assert len ( partial_msgs ) > 0 , \
f " Expected at least one partial assistant msg for tools-only cancel. Got: { assistant_msgs } "
def test_no_reasoning_no_tools_no_partial ( self ) :
""" Cancel with no reasoning and no tools and no text = only cancel marker (no change). """
sid = " test_1361_c3 "
stream_id = " stream_c3 "
s = _make_session ( session_id = sid )
_setup_cancel_state ( sid , stream_id )
config . STREAM_PARTIAL_TEXT [ stream_id ] = " "
cancel_stream ( stream_id )
s2 = models . SESSIONS [ sid ]
assistant_msgs = [ m for m in s2 . messages if isinstance ( m , dict ) and m . get ( ' role ' ) == ' assistant ' ]
# Should only have the cancel marker, no partial messages
partial_msgs = [ m for m in assistant_msgs if m . get ( ' _partial ' ) ]
cancel_msgs = [ m for m in assistant_msgs if m . get ( ' _error ' ) ]
assert len ( partial_msgs ) == 0 , \
f " Expected no partial msg when nothing was streamed. Got partials: { partial_msgs } "
assert len ( cancel_msgs ) == 1 , \
f " Expected exactly 1 cancel marker. Got: { cancel_msgs } "
2026-05-06 20:47:58 +00:00
# ── §D: Error paths must not lose pending user turn ─────────────────────────
def test_stream_error_materializes_pending_user_turn_before_clearing_runtime_state ( ) :
""" If a stream errors before normal merge, pending_user_message must become a
durable user message before the error marker is saved . Otherwise reload / server
reconcile makes the user ' s just-submitted prompt disappear.
"""
from api . streaming import _materialize_pending_user_turn_before_error
sid = " test_pending_error_d1 "
s = _make_session (
session_id = sid ,
pending_msg = " please restart the WebUI " ,
messages = [ { " role " : " assistant " , " content " : " previous answer " } ] ,
)
s . pending_started_at = 1778098700.0
s . pending_attachments = [ { " name " : " screenshot.png " } ]
appended = _materialize_pending_user_turn_before_error ( s )
assert appended is True
assert s . messages [ - 1 ] [ " role " ] == " user "
assert s . messages [ - 1 ] [ " content " ] == " please restart the WebUI "
assert s . messages [ - 1 ] [ " timestamp " ] == 1778098700
assert s . messages [ - 1 ] [ " attachments " ] == [ { " name " : " screenshot.png " } ]
assert s . pending_user_message == " please restart the WebUI "
def test_stream_error_pending_materialization_does_not_duplicate_eager_checkpoint ( ) :
""" Eager session-save mode may already have checkpointed the current user turn;
the error materializer must not append the same user message again .
"""
from api . streaming import _materialize_pending_user_turn_before_error
sid = " test_pending_error_d2 "
s = _make_session (
session_id = sid ,
pending_msg = " please restart the WebUI " ,
messages = [
{ " role " : " assistant " , " content " : " previous answer " } ,
{ " role " : " user " , " content " : " please restart the WebUI " } ,
] ,
)
appended = _materialize_pending_user_turn_before_error ( s )
assert appended is False
assert [ m . get ( " role " ) for m in s . messages ] . count ( " user " ) == 1
chore(release): stamp v0.51.14 — 4-PR contributor batch (#1756, #1757, #1760, #1761)
Constituent PRs:
- #1760 (@ai-ag2026) preserve pending user turn on stream errors. Closes #1361.
- #1761 (@dso2ng) scope terminal stream cleanup to owner session. Refs #1694.
AUTO-FIX applied: restored !INFLIGHT[S.session.session_id] disjunct in
_setActivePaneIdleIfOwner (regression introduced by helper centralization).
- #1756 (@ng-technology-llc) isolate profile cookie per webui instance. Closes #803.
- #1757 (@skspade) tri-state gateway status (alive: True/False/None).
Tests: 4642 → 4662 collected (+20). 4649 passed, 9 skipped (test-isolation
prong-2 noise), 3 xpassed, 0 failed in 152s.
Pre-release verification:
- All 4 PRs CI-green or rebased clean (#1757 had stale base; CHANGELOG conflict
auto-resolved by dropping the PR's redundant entry).
- node -c clean on static/messages.js + static/panels.js.
- 11/11 browser API endpoints PASS.
- Pre-stamp re-fetch: all PR heads match local rebases.
- Opus advisor: SHIP, all 5 verification questions clean, 0 MUST-FIX, 0 SHOULD-FIX.
- Two NICE-TO-HAVE coverage gaps absorbed in-release:
(1) test_sprint36.py asserts !INFLIGHT[...] disjunct in helper body
(2) test_issue1361_cancel_data_loss.py adds structural-grep test to pin
_materialize_pending_user_turn_before_error call sites at error branches.
Closes #803, #1361, #1694.
2026-05-06 22:20:17 +00:00
2026-05-07 21:57:01 +00:00
def test_stale_stream_cleanup_materializes_pending_turn_before_clearing_state ( ) :
""" A zombie/stale stream repair must preserve the pending user prompt.
If the process dies after chat_start saved pending_user_message but before the
agent merges the user turn , / api / session stale cleanup must not clear that
pending field without first appending a durable user message .
"""
from api . routes import _clear_stale_stream_state
sid = " test_pending_error_d3_stale "
s = _make_session (
session_id = sid ,
pending_msg = " please make the GUI fully usable " ,
messages = [ { " role " : " assistant " , " content " : " previous answer " } ] ,
)
s . pending_started_at = 1778187755.0
s . pending_attachments = [ { " name " : " visible-state.png " } ]
# No matching STREAMS entry: this simulates a dead worker/server restart.
cleared = _clear_stale_stream_state ( s )
assert cleared is True
assert s . active_stream_id is None
assert s . pending_user_message is None
2026-05-17 02:49:35 +00:00
assert s . messages [ - 2 ] [ " role " ] == " user "
assert s . messages [ - 2 ] [ " content " ] == " please make the GUI fully usable "
assert s . messages [ - 2 ] [ " timestamp " ] == 1778187755
assert s . messages [ - 2 ] [ " attachments " ] == [ { " name " : " visible-state.png " } ]
assert s . messages [ - 1 ] [ " role " ] == " assistant "
assert s . messages [ - 1 ] . get ( " _error " ) is True
assert s . messages [ - 1 ] . get ( " type " ) == " interrupted "
2026-05-07 21:57:01 +00:00
reloaded = models . get_session ( sid , metadata_only = False )
2026-05-17 02:49:35 +00:00
assert reloaded . messages [ - 2 ] [ " role " ] == " user "
assert reloaded . messages [ - 2 ] [ " content " ] == " please make the GUI fully usable "
assert reloaded . messages [ - 1 ] [ " role " ] == " assistant "
assert reloaded . messages [ - 1 ] . get ( " type " ) == " interrupted "
def test_stale_stream_cleanup_recovers_journaled_visible_output ( ) :
""" The /api/session stale cleanup path can run before a full chat reload;
it must preserve journaled partial output instead of only clearing runtime
flags . """
from api . routes import _clear_stale_stream_state
sid = " test_pending_error_d4_journal "
s = _make_session (
session_id = sid ,
pending_msg = " please check maintainer activity " ,
messages = [ { " role " : " assistant " , " content " : " previous answer " } ] ,
)
append_run_event (
sid ,
" stream_1361 " ,
" token " ,
{ " text " : " I will check GitHub first. " } ,
)
append_run_event (
sid ,
" stream_1361 " ,
" tool " ,
{ " name " : " terminal " , " preview " : " gh issue view 2423 " , " args " : { " command " : " gh issue view 2423 " } } ,
)
append_run_event (
sid ,
" stream_1361 " ,
" tool_complete " ,
{ " name " : " terminal " , " duration " : 0.4 , " is_error " : False } ,
)
cleared = _clear_stale_stream_state ( s )
assert cleared is True
assert any ( " I will check GitHub first. " in ( m . get ( " content " ) or " " ) for m in s . messages )
assert s . tool_calls
assert s . tool_calls [ 0 ] [ " name " ] == " terminal "
assert s . messages [ - 1 ] . get ( " type " ) == " interrupted "
assert " partial output above was recovered " in s . messages [ - 1 ] [ " content " ]
2026-05-07 21:57:01 +00:00
chore(release): stamp v0.51.14 — 4-PR contributor batch (#1756, #1757, #1760, #1761)
Constituent PRs:
- #1760 (@ai-ag2026) preserve pending user turn on stream errors. Closes #1361.
- #1761 (@dso2ng) scope terminal stream cleanup to owner session. Refs #1694.
AUTO-FIX applied: restored !INFLIGHT[S.session.session_id] disjunct in
_setActivePaneIdleIfOwner (regression introduced by helper centralization).
- #1756 (@ng-technology-llc) isolate profile cookie per webui instance. Closes #803.
- #1757 (@skspade) tri-state gateway status (alive: True/False/None).
Tests: 4642 → 4662 collected (+20). 4649 passed, 9 skipped (test-isolation
prong-2 noise), 3 xpassed, 0 failed in 152s.
Pre-release verification:
- All 4 PRs CI-green or rebased clean (#1757 had stale base; CHANGELOG conflict
auto-resolved by dropping the PR's redundant entry).
- node -c clean on static/messages.js + static/panels.js.
- 11/11 browser API endpoints PASS.
- Pre-stamp re-fetch: all PR heads match local rebases.
- Opus advisor: SHIP, all 5 verification questions clean, 0 MUST-FIX, 0 SHOULD-FIX.
- Two NICE-TO-HAVE coverage gaps absorbed in-release:
(1) test_sprint36.py asserts !INFLIGHT[...] disjunct in helper body
(2) test_issue1361_cancel_data_loss.py adds structural-grep test to pin
_materialize_pending_user_turn_before_error call sites at error branches.
Closes #803, #1361, #1694.
2026-05-06 22:20:17 +00:00
# ── Structural guard: pin call sites of the materialize helper at error branches ──
def test_materialize_helper_called_immediately_before_error_path_clears ( ) :
""" Pin call sites of _materialize_pending_user_turn_before_error.
Catches a future refactor that drops the call from the apperror - no - response
or outer - Exception paths in api / streaming . py while leaving the
` pending_user_message = None ` clearing in place — which is exactly the
user - turn - data - loss regression #1361 was filed for.
Strategy : count how many ` pending_user_message = None ` clearings have the
helper call within the preceding 4 lines . Currently 2 ( apperror at 2610 ,
outer - Exception at 3072 ) . The success path ( 2716 ) and cancel path ( 3375 )
legitimately don ' t need the helper. If a future refactor drops the helper
call from one of the error sites , this assertion fires .
"""
from pathlib import Path
src = Path ( __file__ ) . parent . parent . joinpath ( ' api ' , ' streaming.py ' ) . read_text ( encoding = ' utf-8 ' )
lines = src . splitlines ( )
helper_name = ' _materialize_pending_user_turn_before_error( '
clear_sites = [ ( i + 1 , line ) for i , line in enumerate ( lines )
if ' pending_user_message = None ' in line ]
assert len ( clear_sites ) > = 4 , (
f " Expected ≥4 sites that clear pending_user_message; found { len ( clear_sites ) } . "
f " If api/streaming.py was refactored, re-audit this test. "
)
sites_with_helper = [ ]
for lineno , _ in clear_sites :
prev_block = ' \n ' . join ( lines [ max ( 0 , lineno - 5 ) : lineno - 1 ] )
if helper_name in prev_block :
sites_with_helper . append ( lineno )
# Concretely, PR #1760 wired up the helper at the apperror-no-response
# path and the outer-Exception path. Both must remain wired.
assert len ( sites_with_helper ) > = 2 , (
f " Expected ≥2 clear sites preceded by { helper_name } within 4 lines; "
f " found { sites_with_helper } . PR #1760 / #1361 regression — re-wire the "
f " helper at the error-branch clear sites in api/streaming.py. "
)
2026-05-12 19:53:28 +00:00
2026-05-15 20:00:30 +00:00
def test_cancel_copy_uses_configured_bot_name ( monkeypatch ) :
""" Cancellation copy should use the configured assistant display name. """
import api . streaming as streaming
monkeypatch . setattr ( streaming , ' load_settings ' , lambda : { ' bot_name ' : ' Obryn ' } )
assert streaming . _cancelled_turn_hint ( ) == (
' The run was cancelled by the user before Obryn finished. '
' No provider failure occurred. '
)
assert ' before Obryn finished ' in streaming . _cancelled_turn_content ( )
assert streaming . _classify_provider_error ( ' Task cancelled by user ' ) [ ' hint ' ] == (
' The run was cancelled by the user before Obryn finished. '
' No provider failure occurred. '
)
2026-05-19 13:06:19 +00:00
def test_cancel_copy_uses_profile_name_for_non_default_profile ( monkeypatch ) :
""" Persisted cancellation copy should use profile names outside literal default. """
import api . streaming as streaming
monkeypatch . setattr ( streaming , ' load_settings ' , lambda : { ' bot_name ' : ' Obryn ' } )
session = type ( ' Session ' , ( ) , { ' profile ' : ' research ' } ) ( )
name = streaming . _preferred_agent_display_name_for_session ( session )
assert name == ' Research '
assert ' before Research finished ' in streaming . _cancelled_turn_content ( agent_name = name )
2026-05-15 20:00:30 +00:00
def test_cancel_copy_falls_back_to_hermes_for_blank_bot_name ( monkeypatch ) :
""" Blank or missing bot_name should not leak old persona copy. """
import api . streaming as streaming
monkeypatch . setattr ( streaming , ' load_settings ' , lambda : { ' bot_name ' : ' ' } )
assert streaming . _cancelled_turn_hint ( ) == (
' The run was cancelled by the user before Hermes finished. '
' No provider failure occurred. '
)
2026-05-12 19:53:28 +00:00
class TestCancelStreamIdempotentWithWorkerFinalizer :
""" The worker and explicit cancel endpoint can both finalize the same turn. """
def test_cancel_stream_does_not_duplicate_existing_worker_cancel_marker ( self ) :
sid = " test_1361_idempotent "
stream_id = " stream_idempotent "
_make_session (
session_id = sid ,
messages = [
{ ' role ' : ' user ' , ' content ' : ' Help me debug this ' , ' timestamp ' : 100 } ,
2026-05-15 20:00:30 +00:00
{ ' role ' : ' assistant ' , ' content ' : ' **Task cancelled:** Task cancelled. \n \n *The run was cancelled by the user before Hermes finished. No provider failure occurred.* ' , ' _error ' : True , ' timestamp ' : 101 } ,
2026-05-12 19:53:28 +00:00
] ,
)
_setup_cancel_state ( sid , stream_id )
config . STREAM_PARTIAL_TEXT [ stream_id ] = " partial text before cancel "
cancel_stream ( stream_id )
msgs = models . SESSIONS [ sid ] . messages
cancel_markers = [
m for m in msgs
if isinstance ( m , dict )
and m . get ( ' role ' ) == ' assistant '
and ' task cancelled ' in str ( m . get ( ' content ' ) or ' ' ) . lower ( )
]
partial_idx = next (
i for i , m in enumerate ( msgs )
if isinstance ( m , dict ) and m . get ( ' _partial ' ) and m . get ( ' content ' ) == ' partial text before cancel '
)
marker_idx = next ( i for i , m in enumerate ( msgs ) if m in cancel_markers )
assert len ( cancel_markers ) == 1
assert partial_idx < marker_idx
def test_late_cancel_after_worker_finalized_does_not_add_cancel_marker ( self ) :
sid = " test_1361_late_done "
stream_id = " stream_late_done "
s = Session (
session_id = sid ,
title = " Done Session " ,
messages = [
{ ' role ' : ' user ' , ' content ' : ' finish normally ' , ' timestamp ' : 100 } ,
{ ' role ' : ' assistant ' , ' content ' : ' done normally ' , ' timestamp ' : 101 } ,
] ,
)
s . active_stream_id = None
s . pending_user_message = None
s . pending_attachments = [ ]
s . pending_started_at = None
s . save ( )
models . SESSIONS [ sid ] = s
q = queue . Queue ( )
config . STREAMS [ stream_id ] = q
config . CANCEL_FLAGS [ stream_id ] = threading . Event ( )
mock_agent = Mock ( )
mock_agent . session_id = sid
mock_agent . interrupt = Mock ( )
config . AGENT_INSTANCES [ stream_id ] = mock_agent
config . STREAM_PARTIAL_TEXT [ stream_id ] = ' stale partial snapshot '
assert cancel_stream ( stream_id ) is True
msgs = models . SESSIONS [ sid ] . messages
assert msgs == [
{ ' role ' : ' user ' , ' content ' : ' finish normally ' , ' timestamp ' : 100 } ,
{ ' role ' : ' assistant ' , ' content ' : ' done normally ' , ' timestamp ' : 101 } ,
]
assert q . empty ( ) , " late cancel must not emit a terminal cancel event after done "