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>
Both gateway SSE read loops used urlopen(timeout=600) and re-checked cancel only
BETWEEN lines, so a half-open gateway (TCP open, zero bytes) pinned the worker
for the full 10 minutes and ignored Stop.
Route raw-line iteration through _iter_sse_lines_cancellable and bound the socket
to a byte-silence budget (_gateway_read_timeout_secs, default 120s, env
HERMES_WEBUI_GATEWAY_READ_TIMEOUT). A read timeout is TERMINAL — CPython's
socket.makefile latches _timeout_occurred on the first socket.timeout, after
which every read raises a bare OSError, so the connection can't be resumed. On a
read timeout (or that poisoned-socket OSError) the generator surfaces the user's
Stop (yield b"" -> caller emits cancel) or re-raises so the caller reports the
stall. A stream that keeps emitting bytes within the window never trips it, so a
genuinely alive (if slow) turn is untouched; only >budget of total byte-silence
is treated as a dead gateway. Both loops (runs-API + legacy) changed identically.
The test fake now models the timeout-poison latch (a bare OSError after the first
timeout) so it reflects real socket semantics; the previous stateless re-raise
had made a multi-window "recovery" path look green that cannot occur in
production.
Refs #2476
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-token hot path mirrors partial text, reasoning, and live tool
calls into STREAM_PARTIAL_TEXT / STREAM_REASONING_TEXT /
STREAM_LIVE_TOOL_CALLS without holding STREAMS_LOCK, while snapshot
readers (_snapshot_and_append_partial_on_error, cancel_stream) read them
under STREAMS_LOCK. This is safe under the CPython GIL for two reasons —
NOT because `+=` is one bytecode (it is not: `d[k] += s` compiles to
load / add / STORE_SUBSCR):
1. Single writer — only the streaming thread ever writes a given
stream_id's buffers, so there is no writer/writer race to tear.
2. Reader/writer atomicity — `+=` builds a complete new immutable str,
then the final STORE_SUBSCR binding it into the dict is a single
atomic bytecode, so a concurrent reader's dict.get returns either
the old or new *complete* string (strings are immutable), never a
half-built one. list.append / single-key dict writes are atomic too.
So a reader sees a complete-but-possibly-stale value, never a torn one,
and the snapshot is a best-effort partial reconciled by later journal/SSE
events.
Chose documentation over locking: pulling the writes under STREAMS_LOCK
would add per-token contention against readers copying large buffers and
entangle the documented LOCK -> STREAMS_LOCK ordering (deadlock risk) for
only a recoverable staleness window. Make the assumption explicit at the
write sites and the reader instead. No behaviour change.
Rollback: revert this commit (comments only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three fixes for the CI failures (lint + test shard 1):
1. B009 lint fix (api/routes.py:6281): Replace getattr(session, 'profile')
with session.profile — the guard on line 6275 already ensures it exists.
2. B905 lint fix (api/routes.py:13843): Add strict=True to zip() of
_draft_stages slices. Both sides are always the same length (shifted
by one), so strict=True is correct.
3. _emit_slow format-arg fix (api/request_diagnostics.py:186-195):
Keep the prefix in the format string and pass the JSON payload as a
separate positional argument. The test at
test_issue1855_request_diagnostics.py:33 does
json.loads(caplog.records[0].args[0]) and the old code embedded the
prefix in the payload string, breaking the JSON parse. Now
self.logger.warning("%s %s", prefix, payload) — wait, actually
self.logger.warning(f"{prefix} %s", payload) — so args[0] is the
raw JSON string, same as the pre-_emit_slow code.