Files
hermes-webui/tests/test_issue5941_errored_turn_response_visible.py
0037061cfd Release exp-v0.52.45: errored-turn response visible (#5941) + recovery honors model pick (#5924) (#5964)
* fix(streaming): keep errored-turn assistant response visible (#5941)

An errored turn that produced assistant content (tool calls + reasoning)
folded that content into a collapsed worklog above the error card, so the
user read a lone error bubble as "nothing came back". The settled-scene
renderer collapsed the worklog unconditionally, never consulting the
turn terminal_state. Now an errored/failure terminal_state keeps the
worklog expanded by default (unless the user explicitly collapsed it),
while completed turns and genuinely-empty errored turns are unchanged.

Reported by @b3nw.

* fix(review): update #4970 brittle collapsed-expr assertion for #5941

test_issue4970_stream_done_shrink_regression asserted the literal
`collapsed:!keepSettledWorklogOpen` in _renderSettledAnchorSceneForMessage.
#5941 OR'd an errored-turn keep-open term into that expression
(`collapsed:!(keepSettledWorklogOpen||erroredWorklogKeepOpen)`), so the
substring no longer matched and the test failed in the full suite (the PR
updated its own touched tests but missed this pre-existing one).

Re-anchor to `collapsed:!(keepSettledWorklogOpen` — still guards #4970's
invariant (keepSettledWorklogOpen negates into the settled-render collapsed
flag) without pinning the exact term list. #4970 + #5941 suites green.

* fix(chat): honor explicit model pick on post-failure recovery send (#5924)

The onchange explicit-pick marker is single-shot: send() consumes it once.
submitEdit() and cmdRetry() truncated and called send() directly without
re-arming it, so the recovery send went out with explicit_model_pick=false and
the server's compatible-model resolution re-reverted a freshly-picked
cross-family model back to the failed/stale value (Facet 1). Facet 4 is the
same loop keeping the persisted model_provider pinned across fork/refresh.

Re-arm the pending explicit-pick marker from the current selector state
immediately before await send() in both recovery paths. Survives a second
consecutive recovery send. Normal send path and the #3737/#5731 server repair
guard are unchanged.

Reported by @b3nw.

* fix(#5924): gate recovery re-arm on genuine pick + session-race guards

Codex gate found 4 defects on the recovery send path; fixed all:
- /retry + edit-resubmit re-armed the explicit-pick marker UNCONDITIONALLY, forcing
  explicit_model_pick even with no fresh pick (suppressed server compatible-model
  resolution). Now gated on _recoveryPick (selector model differs from session's own
  stored model), captured pre-await.
- session-switch races: added active-session guards after the retry GET await and the
  edit truncate await so session A's recovery intent can't apply to session B.
- 3 new regression tests (gated re-arm, pre-await capture, post-await re-guard).

* fix(#5924) round-2: derive recovery pick from non-default session model, not inference

Codex round-2 CORE: the state-comparison predicate false-negatived an already-applied
pick (consumed marker → looks unchanged) and false-positived on provider inference
(@removed:mistral-large + null stored provider → inferred 'removed' → fake change).
Replace with _deliberateSessionModelPick(sid): reports {model,provider} only when the
session's OWN model is genuinely non-default vs window._defaultModel/_activeProvider —
inference-free + survives marker consumption. Both recovery paths + tests updated.

* fix(#5924) round-3: require known-default+owned-provider evidence + fire-time re-arm guard

Codex round-3: (1) _deliberateSessionModelPick still false-positived when the
profile default was unknown or the provider was only inferred — now requires a
session-OWNED provider AND a known window._defaultModel/_activeProvider, else
fails closed. (2) new same-session race: a model change DURING the recovery awaits
made the pre-await pick stale and clobbered a newer marker — new _reArmRecoveryPick
helper re-arms only if the current session model/provider still equals the captured
pick AND no different pending marker exists. Both recovery paths route through it.

* Release exp-v0.52.45: errored-turn response stays visible (#5950/#5941) + recovery honors model pick (#5949/#5924)

* fix: keep absoluteKeepCount capture before _recoveryPick in submitEdit

The #5924 _recoveryPick comment contained the word 'await', tripping the
pre-existing test_issue_edit_regenerate_absolute_keep_count regex (first \bawait\b
must come after the absoluteKeepCount capture). Reorder both synchronous pre-network
captures (absoluteKeepCount first) + reword the comment. No behavior change.

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-07-11 18:07:28 -07:00

190 lines
9.6 KiB
Python

"""Regression tests for #5941: an errored turn's produced response stays VISIBLE.
Reported by b3nw (Discord #report-bugs). When a turn ends in a provider/agent
error but the assistant HAD already produced content (tool calls + reasoning),
the settled-scene render folded that whole turn into a *collapsed* worklog above
the error card. The user saw only the error bubble and reasonably concluded that
nothing came back — even though the real response was one click away behind the
collapsed header.
Root cause: `_renderSettledAnchorSceneForMessage` (static/ui.js) built the
settled worklog with `collapsed: !keepSettledWorklogOpen` UNCONDITIONALLY — the
errored turn's `terminal_state` (error / no_response / tool_limit_reached / ...)
was never consulted, so an errored-but-content-bearing turn collapsed exactly
like a normal completed turn.
Fix: classify the scene's `terminal_state`. When it is an error/failure state
(NOT a normal `completed`), keep the settled worklog EXPANDED by default so the
produced response stays visible, unless the user has explicitly collapsed THIS
turn's worklog (saved 'closed' disclosure state). The genuinely-empty errored
turn is unaffected: the render gate at the top of the function requires a
worklog-worthy scene (>=1 tool/thinking/compression row), so a real no_response
with zero produced content never reaches the collapse decision and still shows
only its error card.
Behavioral tests extract the real predicate from static/ui.js and execute it in
Node; structural tests lock the wiring into the render gate.
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
def _function_body(src: str, name: str) -> str:
marker = f"function {name}"
start = src.index(marker)
brace = src.index("{", start)
depth = 0
for idx in range(brace, len(src)):
ch = src[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[brace + 1 : idx]
raise AssertionError(f"function {name} body not found")
def _extract(src: str, name: str) -> str:
marker = f"function {name}"
start = src.index(marker)
body = _function_body(src, name)
sig = src[start : src.index("{", start)]
return f"{sig}{{{body}}}"
def test_render_gate_wires_errored_terminal_state_into_collapse_decision():
"""Structural lock: the settled-scene collapse decision must honor the
errored-terminal-state predicate, not just keepSettledWorklogOpen."""
body = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage")
# The predicate is computed for this turn's scene...
assert "_anchorSceneHasErroredTerminalState(scene)" in body, (
"render gate must classify the scene's terminal_state"
)
# ...an explicit user-collapsed worklog is still respected...
assert "_readActivityDisclosureState(activityKey)!=='closed'" in body, (
"default-open must not override an explicit user collapse"
)
# ...and it feeds the collapsed:! decision alongside keepSettledWorklogOpen.
assert "collapsed:!(keepSettledWorklogOpen||erroredWorklogKeepOpen)" in body, (
"errored-turn keep-open must flow into the worklog collapsed flag"
)
# The worklog-worthiness guard is still the entry gate (empty errored turns
# never reach the collapse decision → they keep their bare error card).
assert "_anchorSceneSceneHasWorklogWorthyRows(message._anchor_activity_scene)" in body
# Predicate definition exists.
assert "function _anchorSceneHasErroredTerminalState" in UI_JS
@pytest.mark.skipif(NODE is None, reason="node required for behavioral test")
def test_errored_terminal_states_keep_content_visible_completed_does_not():
"""Errored/failure terminal states → true (keep visible); completion / null
/ user-stop states → false (unchanged behavior)."""
predicate = _extract(UI_JS, "_anchorSceneHasErroredTerminalState")
# Pull the shared Set constant the predicate closes over.
set_line_start = UI_JS.index("const _ANCHOR_SCENE_ERRORED_TERMINAL_STATES=new Set([")
set_line_end = UI_JS.index("]);", set_line_start) + len("]);")
set_decl = UI_JS[set_line_start:set_line_end]
harness = textwrap.dedent(f"""
{set_decl}
{predicate}
const out = {{}};
// (1) Provider/agent errors that produced content must keep it visible.
out.error = _anchorSceneHasErroredTerminalState({{ terminal_state: 'error' }});
out.no_response = _anchorSceneHasErroredTerminalState({{ terminal_state: 'no_response' }});
out.degraded = _anchorSceneHasErroredTerminalState({{ terminal_state: 'degraded' }});
out.connection_lost = _anchorSceneHasErroredTerminalState({{ terminal_state: 'connection_lost' }});
out.tool_limit_reached = _anchorSceneHasErroredTerminalState({{ terminal_state: 'tool_limit_reached' }});
out.compression_exhausted = _anchorSceneHasErroredTerminalState({{ terminal_state: 'compression_exhausted' }});
out.error_upper = _anchorSceneHasErroredTerminalState({{ terminal_state: 'ERROR' }});
// (2) A normal completed turn must NOT be force-opened (default collapse stays).
out.completed = _anchorSceneHasErroredTerminalState({{ terminal_state: 'completed' }});
// (3) User-initiated stops keep their existing behavior (own dedicated cards).
out.cancelled = _anchorSceneHasErroredTerminalState({{ terminal_state: 'cancelled' }});
out.interrupted = _anchorSceneHasErroredTerminalState({{ terminal_state: 'interrupted' }});
// (4) Missing / null terminal_state → false.
out.null_state = _anchorSceneHasErroredTerminalState({{ terminal_state: null }});
out.no_field = _anchorSceneHasErroredTerminalState({{}});
out.no_scene = _anchorSceneHasErroredTerminalState(null);
console.log(JSON.stringify(out));
""")
res = subprocess.run([NODE, "-e", harness], capture_output=True, text=True, timeout=30)
assert res.returncode == 0, res.stderr
out = json.loads(res.stdout.strip())
# Errored/failure states keep produced content visible.
for state in (
"error",
"no_response",
"degraded",
"connection_lost",
"tool_limit_reached",
"compression_exhausted",
"error_upper",
):
assert out[state] is True, f"errored terminal_state '{state}' must keep response visible"
# Normal completion + user stops + absent state are unchanged (not force-open).
for state in ("completed", "cancelled", "interrupted", "null_state", "no_field", "no_scene"):
assert out[state] is False, f"terminal_state '{state}' must NOT be treated as an errored keep-open"
@pytest.mark.skipif(NODE is None, reason="node required for behavioral test")
def test_errored_worklog_keep_open_decision_matrix():
"""End-to-end of the exact collapse expression the render gate evaluates:
erroredWorklogKeepOpen = hasErroredTerminalState(scene)
&& savedDisclosure !== 'closed'
collapsed = !(keepSettledWorklogOpen || erroredWorklogKeepOpen)
Encodes the #5941 invariant plus its guards:
* errored + content-bearing (default disclosure) -> visible (not collapsed)
* errored + content-bearing, user explicitly collapsed -> collapsed (respected)
* completed + content-bearing -> collapsed (unchanged)
"""
predicate = _extract(UI_JS, "_anchorSceneHasErroredTerminalState")
set_line_start = UI_JS.index("const _ANCHOR_SCENE_ERRORED_TERMINAL_STATES=new Set([")
set_line_end = UI_JS.index("]);", set_line_start) + len("]);")
set_decl = UI_JS[set_line_start:set_line_end]
harness = textwrap.dedent(f"""
{set_decl}
{predicate}
function decide(scene, savedDisclosure, keepSettledWorklogOpen) {{
const erroredWorklogKeepOpen =
_anchorSceneHasErroredTerminalState(scene) && savedDisclosure !== 'closed';
const collapsed = !(keepSettledWorklogOpen || erroredWorklogKeepOpen);
return collapsed;
}}
const out = {{}};
// Errored turn that produced content, no explicit user disclosure → VISIBLE.
out.errored_default = decide({{ terminal_state: 'error' }}, null, false);
// Same errored turn, but the user explicitly collapsed it → stays collapsed.
out.errored_user_collapsed = decide({{ terminal_state: 'error' }}, 'closed', false);
// Errored turn the user explicitly opened → visible.
out.errored_user_open = decide({{ terminal_state: 'no_response' }}, 'open', false);
// A normal completed turn collapses as before.
out.completed_default = decide({{ terminal_state: 'completed' }}, null, false);
console.log(JSON.stringify(out));
""")
res = subprocess.run([NODE, "-e", harness], capture_output=True, text=True, timeout=30)
assert res.returncode == 0, res.stderr
out = json.loads(res.stdout.strip())
assert out["errored_default"] is False, (
"an errored turn that produced content must render its worklog EXPANDED (visible) by default"
)
assert out["errored_user_collapsed"] is True, (
"a worklog the user explicitly collapsed must stay collapsed even on an errored turn"
)
assert out["errored_user_open"] is False, "a user-opened errored worklog stays visible"
assert out["completed_default"] is True, (
"a normal completed turn must keep its default-collapsed worklog (no behavior change)"
)