* 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>
* feat(#3351): add trusted-header auth core
* fix(#3351): reconcile trusted sessions per request
* fix(#3351): preserve valid trusted group mappings
* fix(#3351): defer trusted logout reconciliation until after csrf
* fix(auth): clear _pending_set_cookies in reset_trusted_auth_request_state (keep-alive cookie cross-request leak)
Codex gate CORE finding: a Set-Cookie queued in request N but not flushed
survived onto the reused HTTP/1.1 handler and could be emitted by request N+1
— after trusted-identity rotation on logout this could overwrite a valid login
cookie and 401 the user. Reset the queue at the per-request boundary + add a
keep-alive regression test.
* docs(.env.example): document trusted-header auth / reverse-proxy SSO env vars
HERMES_WEBUI_TRUSTED_AUTH_HEADER + TRUSTED_PROXY_CIDRS (with exact-proxy-IP
security warning) + optional groups-header/JSON group-profile-map/logout-url.
* Release exp-v0.52.43: trusted-header auth / reverse-proxy SSO (#5568, #3351) + keep-alive cookie fix + docs
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix(reasoning): stop date-stamped Claude 3.0 ids from reading reasoning-capable
_candidate_supports_reasoning captured a trailing date stamp as the minor
version so bare date-stamped Claude 3.0 ids like claude-3-opus-20240229
satisfied the major==3 and minor>=7 gate and wrongly surfaced reasoning-effort
controls. Cap the minor group to 1-2 digits with a (?!\d) guard so the date
stamp is not read as a minor version -- the same date-stamp defense
_is_pre_adaptive_anthropic already uses. Claude 3.0/3.5 now correctly report
no reasoning support; 3.7+ and 4.x (incl. date-stamped builds) are unaffected.
* test(reasoning): cover date-stamped Claude 3.0 not reasoning-capable
Locks the heuristic so claude-3-opus-20240229 (and sibling date-stamped 3.0
ids) report no reasoning support while 3.7+/4.x stay capable.
* fix(session): classify ACP adapter sessions into the CLI sidebar family
Gateway ACP adapter sessions (source='acp' in state.db — Zed, external
device bridges like a Rabbit R1) normalised to session_source 'other',
which fell through BOTH sidebar buckets: sidebar_source=webui skips the
state.db projection entirely (and ACP rows have no WebUI sidecar), while
sidebar_source=cli keeps only CLI-classified rows. Result: ACP
conversations were invisible and could never be clicked/imported.
Classify 'acp' as a CLI-family source, mirroring 'tui' (a local
interactive agent client): normalize_agent_session_source maps it to
session_source 'cli' with label 'ACP', is_cli_session_row recognises the
raw source, is_cli_session_row_visible keeps ended/untitled ACP rows
visible (they are always user-driven), and frontend _isCliSession
accepts raw === 'acp'. Zero-message ACP connect/reconnect stubs stay
hidden via the existing message-count guard. Claim policy is untouched:
ACP stays claimable like CLI/TUI per the documented 'future local agent
sources' allowance in _is_claimable_cli_source.
Rollback: revert this commit (pre-change file copies also in
~/.hermes/backups/acp-sidebar-fix-20260711/), then restart WebUI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(session): require a user turn before surfacing ACP rows
An ended ACP connection can record only assistant/tool/system messages
(replayed or aborted turn), which the blanket TUI-style visibility
exception would surface despite not being user-driven. Gate the ACP
branch on _count_user_turns(row) > 0, per Greptile review on #5939.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Release exp-v0.52.40: ACP sessions in sidebar (#5939) + date-stamped Claude 3.0 reasoning-control fix (#5934)
---------
Co-authored-by: 黄云龙 <76432572+nankingjing@users.noreply.github.com>
Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix(transparent-stream): stop compact worklog from hijacking transparent live render (#5942, #5943)
Transparent Stream turns with tool calls alternated between the grouped compact
worklog summary ("Processed Ns") and the individual per-event row list (#5942), and
rebuilt every live row element each tick causing flicker (#5943, regression of #5367).
Root cause (bisected to the #5739 merge as a MERGE INTERACTION; #5700/#5739 timestamp
code is NOT causal): #5746 (final-answer-only mode) reworked renderLiveAnchorActivityScene
so a caller-supplied `requestedMode` (opts.mode) OVERRODE the user's active display mode.
Several live callers (appendLiveToolCard, ensureLiveWorklogShell, appendLiveCompressionCard,
appendLiveThinking, session restore in sessions.js) hardcode {mode:'compact_worklog'}.
Before #5746 the dispatch checked isTransparentStream() FIRST and ignored that hint; after,
those callers forced the compact grouped render onto a transparent turn. The compact pass
deletes the keyed transparent rows, so the next transparent tick's preserveByKey reuse
(the #5367 element-reuse fix) misses -> it rebuilds a grouped frame then tears it down and
recreates every row = alternating + flicker.
Fix (two layers, defence in depth):
1. renderLiveAnchorActivityScene: the USER's active chatActivityMode() is authoritative;
opts.mode is only a fallback when there is no usable active mode. A compact_worklog hint
can no longer override an active transparent_stream (restores the good-build precedence).
2. Removed the now-dead hardcoded {mode:'compact_worklog'} argument from all 8 current-view
live/restore call sites (ui.js x5, sessions.js x3) so intent is explicit and the helpers
resolve the active mode via chatActivityMode().
Compact_worklog folding, hide_all_activity suppression, the #5700/#5739 timestamp feature,
and #5860 lazy settled-worklog materialization are all unaffected.
Verified with the crown-jewel stream regression gate (private workspace): transparent_stream
goes rowElementSwaps 1105->0 and groupedFramesInTransparent 42->0; hide_all_activity clean.
Independently root-caused + fix-shape-confirmed by Codex adversarial review.
* fix(transparent-stream): also make projection mode active-authoritative in _renderLiveAnchorActivitySceneForStream (Fable advisory — kill latent requestedMode-wins drift)
* test(#5942/#5943): update brittle source-string assertions for removed {mode:'compact_worklog'} arg
test_live_to_final_anchor_visible_order.py asserted the literal presence of the now-removed
hardcoded compact-mode arg (in ensureLiveWorklogShell + the sessions.js restore path). The fix
correctly drops that arg so the helpers resolve the active display mode. Updated both to assert
the mode-free call / active-mode behavior instead of the removed literal. 63/63 in-file pass.
* revert wrapper projection-mode change (preserve #3820 explicit-mode contract)
The follow-up 8f654f0c0 made _renderLiveAnchorActivitySceneForStream projection-mode
active-authoritative, but that broke the #3820 contract (test_issue3820: explicit hide_all
honored when active=compact; hide_all-active always wins). Fable flagged this only as an
advisory/latent cleanup, NOT required — the real fix is the central renderLiveAnchorActivityScene
dispatch + call-site arg removal, both proven by the gate. Reverting the wrapper restores #3820
(109/109 targeted tests pass) with the bug fix fully intact.
* test(#1426): make OpenRouter live-fetch test hermetic by mocking curated fetch
test_openrouter_group_uses_live_fetch_when_available failed deterministically
in any environment where the hermes_cli agent package is installed AND the
network is reachable, e.g.:
pytest "tests/test_issue1426_openrouter_free_tier_live_fetch.py::\
test_openrouter_group_uses_live_fetch_when_available" -p no:xdist
assert 'minimax/minimax-m2.5:free' in
['anthropic/claude-sonnet-4.6', 'anthropic/claude-fable-5', ...]
Root cause — a gap in the test's mock, not a production regression:
get_available_models() populates the OpenRouter group from TWO live fetches:
(1) hermes_cli.models.fetch_openrouter_models() — the curated catalog.
This routes through hermes_cli's open_credentialed_url(), which builds
its own opener and calls .open(); it does NOT go through the
module-level urllib.request.urlopen symbol the test monkeypatches.
(2) a direct urllib.request.urlopen(".../v1/models") free-tier augment —
this one IS intercepted by the monkeypatch.
The test only patched urllib.request.urlopen, so fetch (1) hit the real
network and returned ~36 live curated models. Those filled the visible picker
cap (15), pushing the mocked free-tier entries (minimax/minimax-m2.5:free,
openrouter/elephant-alpha) into the group's extra_models bucket. The
assertion only inspected the visible `models` bucket, so it failed. In CI
(hermes_cli absent) fetch (1) raised ImportError, the curated base was empty,
the 2-3 mocked entries fit the visible cap, and the test passed — hence the
environment-dependent failure.
Production is correct: free-tier models DO surface in the picker (verified
they land in extra_models). The fix keeps the coverage intent intact:
- Mock fetch (1) too (as sibling tests already do) so the test is genuinely
hermetic — matching this module's docstring promise of "without depending
on real network access" — with a small curated base so live data, not the
fallback list, is exercised.
- Assert the free-tier IDs surface across BOTH picker surfaces (`models` +
`extra_models`), matching the sibling fail-closed and cap tests, so a
large curated base overflowing the visible cap no longer flips the result.
No production code changed.
* Release exp-v0.52.39: transparent-stream mode-precedence fix (#5946/#5942/#5943) + hermetic OpenRouter live-fetch test (#5947/#1426)
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Auxiliary-model task definitions were maintained in BOTH Python and JS and could
drift (the retired session_search task lingered in one). Centralize to a single
backend catalog that panels.js renders+saves from exclusively; purge the retired
slot on reset while preserving other custom bindings; old configs still load.
Gate-pass: Codex SAFE (only session_search removed, 11 other tasks + defaults
unchanged, 22 keys match across 15 locales, legacy migrate preserves custom
bindings, executable DOM harness rendered all rows). 53 own + 887 regression green.
Co-authored-by: t <a@b>
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
* fix(updates): scope installed-version fallback to webui + verified current_sha (#5864 gate fixes)
Gate (Codex) found 2 CORE in the #5862/#5864 experimental-version-display fix:
1. api/updates.py:947 — the installed-version fallback ran in shared _check_repo_release
for BOTH repos, injecting WEBUI_VERSION (v0.52.0, not an Agent ref) into Agent payloads.
Now scoped to name=='webui'.
2. api/updates.py:996 — current_sha reused current_version_display, but WEBUI_VERSION can be
vX.Y.Z-dirty-<hash>/-N-g<sha>/bare-SHA/unknown (not git refs) -> broken /compare link +
lost update-summary subjects. current_sha now derives from a git-VERIFIED exact-match tag
(channel-neutral) or None; current_version keeps the real display string.
+2 regression tests (agent-repo-no-inject, dirty-HEAD-verified-sha). 21/21 update-channel green.
* changelog: #5864 experimental-channel version display fix (#5862)
---------
Co-authored-by: t <a@b>
Reasoning-heavy sessions accumulate 250-480KB of anchor_activity_scenes serialized
BEFORE the messages array, so the 64KB metadata-prefix read always overflowed ->
full-parse fallback on every read -> memory growth. Move scene data AFTER messages;
metadata prefix now ~2KB, modern reads skip the full parse. Old-layout files still
load (one authoritative parse then cached); round-trip preserves scenes exactly.
Independent review: nesquena APPROVED (end-to-end, on head). Gate-pass: Codex SAFE
(933KB legacy sidecar loads clean + round-trip verified), 17 own + 2945 regression green.
Co-authored-by: t <a@b>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
On .docx/.xlsx/.pptx upload, write a best-effort <name>.md sidecar with extracted
text (reuses api/office_documents.py — no new dep). Path-contained (O_NOFOLLOW
openat-walk within workspace), collision-safe (O_CREAT|O_EXCL never overwrites an
existing .md), best-effort (sidecar failure leaves the Office file intact, partial
cleanup on failure). Nathan concept-approved.
Gate-pass: Codex SAFE (containment + exclusivity + best-effort all verified), 27 own
+ 1443 upload/workspace regression tests green.
Co-authored-by: t <a@b>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(security): trusted-proxy-verified local-origin gate (maintainer fix of #5764)
Rebuild _onboarding_request_is_local with a single, symmetric trust model that
closes the embedded-terminal local-gate bypass AND fixes the direct-LAN lockout
regression from the contributor's #5764 attempt (bounced with these findings):
1. SPOOF CLOSED: a forwarded client IP is honored ONLY when the un-spoofable raw
socket peer is a trusted proxy (loopback, or HERMES_WEBUI_TRUSTED_PROXY_CIDRS).
A public direct client sending X-Forwarded-For: 127.0.0.1 is judged on its raw
public peer -> DENY. New _raw_peer_is_trusted_proxy + _forwarded_client_ip_from
_trusted_proxy consume the FULL XFF chain (get_all, repeated headers), walk it
right-to-left skipping trusted hops, and fail closed on empty/blank/garbage.
2. NO LAN LOCKOUT: a direct loopback/LAN client with no proxy header stays local
(onboarding, first-password/passkey, passwordless terminal keep working).
3. COMPAT: TRUST_FORWARDED_FOR=1 stays the opt-in that consults the chain; the new
TRUSTED_PROXY_CIDRS only WIDENS trust to a non-loopback proxy (loopback always
trusted). Shipped private-peer-with-forwarded-header deny nuance preserved.
Updated the one existing test that encoded the vulnerable behavior; added a
21-case adversarial truth-table regression (tests/test_cvd3_terminal_local_origin_gate.py).
66 gate tests + 720 auth/terminal regression pass.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* fix(security): family-aware trusted-proxy membership (IPv4-mapped-IPv6)
Codex re-gate CORE: a mapped-IPv6 address (::ffff:10.9.9.9) never matched an
IPv4 CIDR allowlist via 'addr in net' -> (1) legit proxy treated as untrusted =
lockout; (2) mapped trusted HOP in the XFF chain mis-returned as client -> a
preceding public client admitted to the terminal gate. New _ip_in_networks
helper checks addr AND addr.ipv4_mapped; both the peer check and the chain-walk
_is_trusted_hop route through it. +3 mapped-IPv6 regression cases (incl the
public-client-hidden-behind-mapped-trusted-hop security case).
---------
Co-authored-by: t <a@b>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
_workspaceRouteForPath now resolves app-relative /api/... routes against document.baseURI
so the direct consumers (previewImg.src, media/pdf/html frame src, download href,
window.open) work under a subpath mount instead of 404ing at the server root. Root-mount
deployment unaffected (identical final URLs); api() callers not double-prefixed.
Gate-pass: Codex SAFE (no root-mount regression, all 6 consumers verified), 825 workspace
regression tests + browser-smoke green. Nathan-approved.
Co-authored-by: t <a@b>
Co-authored-by: tomtong2015 <tomtong2015@users.noreply.github.com>
Fixes the #5831 brick: "WebUI sessions (N)" chip showed a count but an empty list.
Server is_cli_session_row classified read-only external_agent imports (Claude Code,
Codex) with a real title as non-CLI (counted under webui_session_count), while the
client _isCliSession filed them in the CLI bucket -> mismatch. Server now classifies
external_agent/external-agent as CLI, matching the client. WebUI/messaging/subagent
unaffected (their exclusions precede the new branch); external_agent imports stay read-only.
Self-built (nesquena-hermes); independent nesquena review APPROVED on this head (17:28);
gate-pass (Codex SAFE, no over-capture, caller audit clean), 14 own + 180 regression green.
Co-authored-by: t <a@b>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
WebUI hard token-cap on protected-tail tool-result payloads in the MODEL CONTEXT
only, applied once after a confirmed compression event, to prevent post-compression
context blowup. Deep-copy content-replace only (no message removal/reorder → no
orphaned tool_result); visible transcript (s.messages) untouched.
Deep-review re-gate: Codex SAFE (pairing-safety + context-only + idempotent verified
by reproduction), 65 own + 992 regression tests green. Crown-jewel streaming path.
Co-authored-by: t <a@b>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Two visible render PRs, gate-passed (deep-review round), Nathan-approved to ship.
- #5757 (rodboev): mark active model with a Selected badge + open its provider group (#5748).
Screenshot-verified (badge clear + correctly placed on active row).
- #5758 (rodboev): suppress the live-token prose row that is a strict prefix of the final
answer (#5749). Provenance-gated (live-prose:* token rows after last tool row only) so it
can't eat legitimate narration; reproduction test proves the dupe is gone.
Codex SAFE + full regression green (1576 render tests). 18 own tests pass together.
Co-authored-by: t <a@b>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Steering a live gateway-owned run had no process-local agent in SESSION_AGENT_CACHE,
so it errored with "No agent cached" and dropped the typed text. Now detects the live
gateway ACTIVE_RUNS stream and queues the steer as the next turn (model/provider/profile
snapshotted first), updates the queue badge, clears the draft, shows a toast — without
cancelling the stream. Gateway sibling of the shipped #5597 fallback.
Codex SAFE + Fable SHIP-UX; byte-identical to PR head; 66 steer/busy-input tests pass;
Nathan visual-approved (desktop + mobile screenshots).
Co-authored-by: t <a@b>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Phase 1 low-risk backend batch (on top of Batch A). Both byte-verified vs PR head,
Codex SAFE, 64 own + 749 regression tests pass, coexists with Batch A's #5833.
- #5828 (ai-ag2026): frame CORS OPTIONS preflight with Content-Length: 0 (keep-alive hang fix)
- #5834 (ai-ag2026): parse session _index.json from bytes not decoded str (all 10 sites)
Co-authored-by: t <a@b>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
Complete Czech locale (1642/1642 key parity, all function values, login screen, test guard). Rebased + translation-quality fixes by maintainer; original author @ostravajih. Gate: full suite + Codex SAFE TO SHIP.
renderFileTree() clears its scroll container with box.innerHTML='' and rebuilds
every row. Detaching the rows collapses scrollHeight, so the browser clamps
scrollTop to 0 — every folder expand/collapse, breadcrumb nav, refresh, and
hidden-files toggle that re-runs the renderer teleported the reader to the top of
a long tree, losing their place (the dominant interaction in the panel).
Fix: capture box.scrollTop before the innerHTML='' wipe and restore it after the
normal render tail (after _renderTreeItems repaints and the container is tall
again). A plain scrollTop restore is sufficient and correct here — expand/collapse
insert or remove rows BELOW the clicked disclosure, so the clicked row keeps its
offset from the top; no getBoundingClientRect anchor delta is needed (that's only
for the prepend-above case in the chat messages list). The reporter's data-path
anchor sketch is deliberately NOT used: _renderTreeItems rows carry no data-path,
so that selector would match nothing.
The two early-return paths (no-workspace hides the box; empty-dir has nothing to
scroll) are left untouched — they legitimately reset. The restore guards only the
normal render tail. loadDir's two-render sequence (initial + post-expanded-dirs
prefetch) is handled correctly: each renderFileTree() call independently captures
then restores its own pre-wipe scrollTop across the await.
Test: tests/test_issue5657_filetree_scroll_preserve.py — static guards that the
capture precedes the wipe and the restore follows _renderTreeItems (per the
maintainer test plan; drops the getBoundingClientRect anchor assertion since the
fix deliberately doesn't use it). Fail-without-fix verified; 355 workspace-tree
regression tests green.
Closes#5657.
test_same_stream_in_two_tabs_receives_identical_token_sequence started both
SSE-handler threads then immediately put_nowait'd the tokens + stream_end
without waiting for both threads to subscribe. StreamChannel.put_nowait()
buffers events only while there are zero subscribers and clears that buffer
the moment the first subscriber attaches and broadcasts live — so producing
events in the window after tab A subscribes but before tab B does orphans
tab B (subscribes to a cleared buffer, never sees stream_end, hangs ->
thread.join timeout -> 'every tab should finish' fails). ~20% timing flake
on slow CI runners (3.11 shard-4).
Wait until len(stream._subscribers) == len(handlers) (bounded 5s) before
producing events, so delivery is deterministic regardless of thread-start
timing. Test-only; no production change (buffer-then-broadcast is correct for
the real reconnect case).
Verified: original flakes 2/30 under CPU contention; fixed 30/30.
Fixes#5628
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>