Files
hermes-webui/tests/test_issue1298_cancel_and_activity.py
nesquena-hermes e3a7c93dc6
Some checks failed
Release & Docker / release (push) Has been cancelled
[HELD — independent review pending] Release v0.51.294 — stage-3401 (live-to-final redesign #3401 + 4 deep-review fixes) (#3741)
* Harden interrupted recovery control filtering

* Redesign live-to-final assistant replies

* Fix live activity anchor test fixture

* Fix CI lint issues for live reply tests

* Strengthen live progress prompt contract

* Recover PR #3401 refresh on origin/master

* Repair live-to-final refresh regressions

* Fix live worklog refresh regressions

* Show live footer timer on initial stream start

* Restore live stream shell after reload

* Preserve per-frame live SSE replay cursors

* Preserve reasoning as Worklog Thinking cards

* Quiet Worklog Thinking card styling

* Align Worklog Thinking card styling

* Scope live Worklog Thinking cards by segment

* Suppress exact duplicate settled Thinking

* Close #3401 merge review test gaps

* fix(#3401): resolve 4 deep-review regressions (inline-think, reconnect-dup, neon skin, busy-gate worklog)

Deep review (Codex diff-vs-master + live-browser drive) of the live-to-final refactor
surfaced 4 regressions vs master that the rewritten suite no longer guarded:

1. Inline <think>…</think>answer reasoning vanished — _assistantReasoningPayloadText
   used $-anchored regexes so a leading think block + visible answer extracted nothing
   and the Thinking card never rendered. Removed the 3 $ anchors to match the
   (non-anchored) display stripper. Live: inline-think thinking-only turn now renders.
2. (CORE) reconnect/reload duplicated the live reply — _rememberRunJournalCursor advanced
   a closure-local seq but never wrote INFLIGHT[activeSid].lastRunJournalSeq, so a reload
   replayed the journal from after_seq=0 over restored lastAssistantText. Now mirrors the
   cursor onto INFLIGHT + schedules a throttled persist.
3. Neon skin silently broke — PR deleted the :root[data-skin="neon"] CSS but left Neon in
   the picker. Restored the neon CSS block from master.
4. Settled tool-worklog rebuild gated purely on !S.busy — dropped every prior settled
   turn's worklog when renderMessages re-ran during an active stream (switch-back to an
   in-progress session). Restored master's !S.busy || (S.toolCalls && S.toolCalls.length).
   Live: busy re-render now preserves tool cards (4→4, was 4→0).

Live-verified all 4 + confirmed #3709/#3592 invariants still hold (1 thinking card, none
below footer; distinct siblings preserved). + tests/test_issue3401_deep_review_fixes.py (7).

* test(#3401): realign 3 stale source-shape assertions to the deep-review fixes

Fix commit changed two source literals that existing stage tests scanned for:
- test_live_activity_timeline.py (x2): split anchor 'if(!S.busy){' → the restored
  'if(!S.busy || (S.toolCalls&&S.toolCalls.length)){' guard (fix 4).
- test_run_journal_frontend_static.py: 'after_seq=0' not in source — fix 2's comment
  contained that literal; rephrased the comment to 'the zero floor (after_seq of 0)'.
Intent of all three assertions unchanged; only the matched string updated. No code
behavior change.

* docs(changelog): v0.51.294 — Release JJ (stage-3401, #3401 live-to-final redesign)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Nathan-Hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 12:12:37 -07:00

378 lines
16 KiB
Python

"""Regression tests for #1298 — Activity panel UI state and Stop/Cancel data loss.
Two distinct bugs reported in YanTianlong-01's bug report on v0.50.240:
1. The expanded Activity list collapses automatically when new activity arrives.
2. The latest user message disappears after clicking Stop/Cancel during streaming.
Bug 2 is server-side data loss (the message is gone from session JSON, not just
the in-memory client copy) caused by cancel_stream() clearing pending_user_message
without first persisting it to s.messages. This test suite locks down both fixes.
"""
import pathlib
import queue
import re
import threading
from unittest.mock import Mock
import pytest
import api.config as config
import api.models as models
import api.streaming as streaming
from api.models import Session
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():
config.STREAMS.clear()
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
config.STREAM_PARTIAL_TEXT.clear()
yield
config.STREAMS.clear()
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
config.STREAM_PARTIAL_TEXT.clear()
@pytest.fixture(autouse=True)
def _isolate_agent_locks():
config.SESSION_AGENT_LOCKS.clear()
yield
config.SESSION_AGENT_LOCKS.clear()
def _make_pending_session(session_id="cancel_sid_1298",
pending_msg="Help me debug this issue",
messages=None,
attachments=None):
"""Build a session in mid-stream state: pending_user_message set, messages may be empty."""
s = Session(
session_id=session_id,
title="Test Session",
messages=messages or [],
)
s.pending_user_message = pending_msg
s.pending_attachments = list(attachments or [])
s.pending_started_at = None
s.active_stream_id = "stream_1298"
s.save()
models.SESSIONS[session_id] = s
return s
def _setup_cancel_stream_state(session_id, stream_id="stream_1298"):
"""Wire up STREAMS/CANCEL_FLAGS/AGENT_INSTANCES so cancel_stream() can run."""
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
# ── Server-side: cancel preserves pending_user_message in s.messages ────────
class TestIssue1298CancelPreservesUserMessage:
"""Issue 2: Latest user message disappears after Stop/Cancel during streaming.
Root cause: cancel_stream() at api/streaming.py:2575+ clears
s.pending_user_message before the streaming thread's
_merge_display_messages_after_agent_result() has a chance to merge the
user turn into s.messages. The session is saved with neither
pending_user_message nor a corresponding s.messages entry, so the user's
typed text is lost permanently.
Fix: synthesize a user turn from pending_user_message into s.messages when
the most recent message isn't already that turn.
"""
def test_cancel_synthesizes_user_message_when_messages_empty(self):
"""When the agent thread is killed before it can append the user turn,
cancel_stream() must persist pending_user_message into s.messages so
the typed text survives a session reload."""
s = _make_pending_session(
session_id="cancel_sid_empty",
pending_msg="What's the weather forecast?",
messages=[],
)
stream_id, _agent = _setup_cancel_stream_state(s.session_id)
result = cancel_stream(stream_id)
assert result is True
# Reload from disk to confirm save happened
s2 = models.SESSIONS[s.session_id]
roles = [m.get("role") for m in s2.messages if isinstance(m, dict)]
contents = [m.get("content") for m in s2.messages if isinstance(m, dict)]
assert "user" in roles, (
"Expected user turn synthesized into s.messages — "
f"got roles={roles}"
)
assert "What's the weather forecast?" in contents, (
"Expected pending_user_message text preserved verbatim in s.messages — "
f"got contents={contents}"
)
assert s2.pending_user_message is None, (
"pending_user_message must be cleared after cancel"
)
assert s2.active_stream_id is None
def test_cancel_does_not_double_append_when_streaming_thread_already_merged(self):
"""If the streaming thread won the race and already merged the user turn
into s.messages before cancel_stream() got the lock, cancel must not
append a duplicate."""
prior_user = {"role": "user", "content": "Run a tool for me"}
s = _make_pending_session(
session_id="cancel_sid_already_merged",
pending_msg="Run a tool for me",
messages=[prior_user],
)
stream_id, _agent = _setup_cancel_stream_state(s.session_id)
cancel_stream(stream_id)
s2 = models.SESSIONS[s.session_id]
user_messages = [m for m in s2.messages
if isinstance(m, dict) and m.get("role") == "user"]
# Exactly one user turn — no duplicate
matching = [m for m in user_messages
if "Run a tool for me" in str(m.get("content") or "")]
assert len(matching) == 1, (
"Expected exactly one user turn matching pending_user_message — "
f"got {len(matching)} ({user_messages})"
)
def test_cancel_synthesized_user_message_carries_attachments(self):
"""A cancelled turn that had attachments uploaded should keep them on
the recovered user message."""
s = _make_pending_session(
session_id="cancel_sid_attachments",
pending_msg="Look at this screenshot",
messages=[],
attachments=["bug_screenshot.png", "stack_trace.txt"],
)
stream_id, _agent = _setup_cancel_stream_state(s.session_id)
cancel_stream(stream_id)
s2 = models.SESSIONS[s.session_id]
user_msgs = [m for m in s2.messages
if isinstance(m, dict) and m.get("role") == "user"]
assert user_msgs, "User turn must be persisted on cancel"
recovered = user_msgs[0]
assert recovered.get("attachments") == [
"bug_screenshot.png", "stack_trace.txt"
], (
"Attachment list must be preserved on the synthesized user turn — "
f"got {recovered.get('attachments')}"
)
def test_cancel_no_pending_user_message_does_nothing_extra(self):
"""When there is no pending_user_message (e.g. cancel after the agent
has already returned), cancel_stream() must not synthesize a phantom
user turn."""
s = Session(
session_id="cancel_sid_no_pending",
title="Test",
messages=[{"role": "user", "content": "earlier turn"}],
)
s.active_stream_id = "stream_1298"
s.pending_user_message = None
s.save()
models.SESSIONS[s.session_id] = s
stream_id, _agent = _setup_cancel_stream_state(s.session_id)
cancel_stream(stream_id)
s2 = models.SESSIONS[s.session_id]
user_messages = [m for m in s2.messages
if isinstance(m, dict) and m.get("role") == "user"]
# Still exactly one — the original earlier turn
assert len(user_messages) == 1
assert user_messages[0].get("content") == "earlier turn"
def test_cancel_synthesizes_when_prior_turn_content_is_substring_of_pending(self):
"""Regression for Opus pre-release review of v0.50.246 (PR #1338):
The substring guard in cancel_stream() was symmetric — it would skip
synthesis if the prior user turn's content was a substring of the new
pending message. Common confirmation replies ("ok", "yes", "go") would
match longer follow-up prompts ("ok please continue") and the
synthesis would be skipped, re-introducing the data-loss bug.
The fix: gate the substring check on a timestamp comparison —
only treat the latest user turn as "already merged by the streaming
thread" if its timestamp is at or after pending_started_at. Earlier
turns whose content happens to be a substring must not short-circuit
the synthesis path.
"""
import time as _time
# Prior reply was "ok" (a common short reply).
prior_ts = int(_time.time()) - 60 # 1 minute ago
prior_user = {
"role": "user",
"content": "ok",
"timestamp": prior_ts,
}
s = _make_pending_session(
session_id="cancel_sid_substring_collision",
pending_msg="ok please continue with the analysis",
messages=[prior_user],
)
# The pending turn started AFTER the prior turn was logged.
s.pending_started_at = prior_ts + 10
s.save()
models.SESSIONS[s.session_id] = s
stream_id, _agent = _setup_cancel_stream_state(s.session_id)
cancel_stream(stream_id)
s2 = models.SESSIONS[s.session_id]
user_messages = [m for m in s2.messages
if isinstance(m, dict) and m.get("role") == "user"]
contents = [m.get("content") for m in user_messages]
assert "ok please continue with the analysis" in contents, (
"Pending user message must be synthesized — the substring 'ok' from a prior turn "
"must NOT cause the synthesis to be skipped. "
f"Got contents={contents}"
)
assert len(user_messages) == 2, (
"Expected both the original prior turn AND the synthesized new turn — "
f"got {len(user_messages)} user messages"
)
# ── Client-side: ui.js source-level guards for activity-group state ─────────
class TestIssue1298ActivityGroupExpandPersistence:
"""Issue 1: Expanded Activity list collapses automatically when new
activity arrives.
Root cause:
- ensureActivityGroup() (static/ui.js) creates the live activity group
with `tool-call-group-collapsed` whenever it's missing
- finalizeThinkingCard() force-adds `tool-call-group-collapsed` on every
tool boundary, regardless of user intent
- The user's manually-set expand state lives only on a DOM class list,
so any destroy/recreate cycle (which fires on every thinking → tool →
thinking transition) wipes it.
Fix: track the user's last explicit toggle in a per-turn singleton, and
skip the force-collapse when the user has explicitly expanded.
"""
def test_ui_js_tracks_user_expand_intent_for_live_activity_group(self):
src = (REPO_ROOT / "static" / "ui.js").read_text()
assert "_liveActivityUserExpanded" in src, (
"ui.js must declare a per-turn tracker for the user's expand intent "
"on the live activity group (#1298)"
)
assert "_onLiveActivityToggle" in src, (
"ui.js must expose a helper that records the user's manual toggle "
"of the live activity group"
)
def test_ensure_activity_group_restores_expand_intent(self):
"""ensureActivityGroup() must consult _liveActivityUserExpanded when
creating a fresh live group so the user's prior expand survives the
destroy/recreate cycle."""
src = (REPO_ROOT / "static" / "ui.js").read_text()
# Find the ensureActivityGroup function body
m = re.search(
r"function ensureActivityGroup\(inner, opts\)\{(.*?)\n\}",
src, re.DOTALL,
)
assert m, "ensureActivityGroup() must exist in ui.js"
body = m.group(1)
assert "_liveActivityUserExpanded" in body, (
"ensureActivityGroup() body must reference the user-expand tracker "
"to restore intent on re-create (#1298)"
)
assert "live" in body and "_liveActivityUserExpanded === true" in body, (
"ensureActivityGroup() must override the default `collapsed` flag "
"when the user previously expanded the live group"
)
def test_finalize_thinking_card_respects_user_expand(self):
"""finalizeThinkingCard() must not force-collapse the live Worklog."""
src = (REPO_ROOT / "static" / "ui.js").read_text()
m = re.search(
r"function finalizeThinkingCard\(\)\{(.*?)\n\}",
src, re.DOTALL,
)
assert m, "finalizeThinkingCard() must exist in ui.js"
body = m.group(1)
assert "tool-call-group-collapsed" not in body, (
"Live Worklog must remain expanded until the settled render replaces "
"it with the final collapsed L1 Activity summary."
)
assert "aria-expanded','false'" not in body
def test_inline_onclick_records_user_intent(self):
"""The summary button's click path must call _onLiveActivityToggle
so user clicks update the tracker (#1298)."""
src = (REPO_ROOT / "static" / "ui.js").read_text()
# The summary button is built inline inside ensureActivityGroup.
assert "_onLiveActivityToggle" in src, (
"_onLiveActivityToggle helper must be defined"
)
assert "function _toggleActivityGroup" in src, (
"Activity summary clicks should route through the shared toggle helper"
)
# The inline onclick may delegate to _toggleActivityGroup(); that helper
# must still call _onLiveActivityToggle(group) so user toggles are
# captured into _liveActivityUserExpanded.
m = re.search(r'class="tool-call-group-summary"[^`]*`', src)
assert m, "live activity summary button template must be present"
assert "onclick=\"_toggleActivityGroup(this)\"" in m.group(0), (
"ensureActivityGroup() summary button should use the shared toggle helper"
)
toggle_body = re.search(
r"function _toggleActivityGroup\(summary\)\{(.*?)\n\}",
src,
re.DOTALL,
)
assert toggle_body and "_onLiveActivityToggle(group)" in toggle_body.group(1), (
"_toggleActivityGroup() must invoke _onLiveActivityToggle(group) "
"so user clicks update the tracker"
)
def test_clear_live_tool_cards_resets_expand_intent(self):
"""clearLiveToolCards() — invoked between turns — must reset the
per-turn user-expand tracker so the next turn starts collapsed by
default (#1298)."""
src = (REPO_ROOT / "static" / "ui.js").read_text()
m = re.search(
r"function clearLiveToolCards\(\)\{(.*?)\n\}",
src, re.DOTALL,
)
assert m, "clearLiveToolCards() must exist"
body = m.group(1)
assert "_clearLiveActivityUserIntent" in body, (
"clearLiveToolCards() must reset _liveActivityUserExpanded between "
"turns so prior expand intent doesn't bleed into the next turn"
)