Commit graph

388 commits

Author SHA1 Message Date
Rod Boev
eb3c5a26bd Clarify terminal marker replacement semantics 2026-06-30 03:33:55 +00:00
Rod Boev
494d137082 fix(chat): scope transcript preserve to the current terminal tail (#5224) 2026-06-30 03:33:55 +00:00
Rod Boev
fd35d8c9ab fix(chat): narrow transcript preservation to stale-prefix restores (#5224) 2026-06-30 03:33:55 +00:00
Rod Boev
c17f222427 fix(chat): preserve visible transcript on terminal stream failure (#5224) 2026-06-30 03:33:55 +00:00
Frank Song
afbb3e5010 Fix session stream resume after live stream end
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-29 19:52:02 +00:00
Frank Song
47d8ac94d3 Harden connection flapping aftercare
Keep sidebar retries boot-scoped, preserve immediate source-change cache rebuilds, and tighten session stream ownership before closing duplicate SSE sockets.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-29 19:52:02 +00:00
Frank Song
cbb644da69 Reduce connection lost flapping under large session state 2026-06-29 19:52:02 +00:00
allenliang2022
955e31b9a1 fix(chat): do not collapse a pure-prose turn into a worklog (stream-done jump)
A turn that streamed only prose (a long plain-text answer, or a degeneration
burst that floods the body with repeated tokens) still projected an anchor
activity scene whose activity_rows were all prose/terminal — zero tool/thinking
rows. The settle path promoted it to a collapsed worklog anyway (the gate only
checked activity_rows.length), hiding the whole answer and shrinking the
transcript by the full streamed height at STREAM_DONE, so the browser clamped a
bottom-pinned viewport back to the top (the 'jump back' report).

Add a worklog-worthiness predicate at both gates: the generation gate
(_anchorSceneHasWorklogWorthyRows in messages.js, decides whether to attach a
scene at all) and the render gate (_anchorSceneSceneHasWorklogWorthyRows in
ui.js, defense-in-depth for already-persisted all-prose scenes). A scene is
worklog-worthy only if it has >=1 tool/thinking row or a compression lifecycle
card; pure prose is not, so the turn renders as normal visible prose and the
viewport stays pinned at the bottom.

Behavioral + structural tests in tests/test_pure_prose_turn_not_worklog.py.
2026-06-29 17:06:45 +00:00
allenliang2022
2aa1d168b0 fix(chat): scope STREAM_DONE keep-open to just-settled turn via one-shot token
Address maintainer must-fix: _shouldKeepSettledWorklogOpenForPinnedFollow()
keyed only on global pin state, so every historical settled worklog re-expanded
on any pinned re-render — defeating the compact default. Gate the keep-open
exception on a one-shot token = the just-settled stream id (armed at STREAM_DONE
renderMessages, disarmed right after), threaded as _shouldKeep...(streamId).
Unpinned path unchanged. Replace source-string test with a behavioral Node test
that drives a second pinned turn and asserts it collapses.
2026-06-29 17:06:44 +00:00
allenliang2022
ada8c262ca fix(webui): render server-initiated turns in a hidden tab without a refresh
A turn started SERVER-SIDE (self-wake, cron, restart hook) fans a
`server_turn_started` frame onto the per-session live-view SSE channel so an
open tab renders it live. But while a tab is HIDDEN the WebUI deliberately does
NOT hold that persistent SSE open (connection-pool budget — #3992 / #4151), so
`get_session_channel()` returns None at fan-out time and the turn is dropped for
that tab. The user only saw the turn after a manual interaction (refresh / send)
re-subscribed the channel and replayed it.

Bridge the gap with a lightweight poll of `/api/session/status` (one short GET
per ~6s tick — NOT a held connection, so the connection-pool budget the hidden
guard protects is preserved) that attaches the existing live renderer when it
sees a *live* active stream.

Backend (`api/session_ops.py`):
- `session_status()` now exposes `active_stream_id`, derived through a new
  `_live_active_stream_id()` helper that only returns the id when the stream is
  genuinely live (present in STREAMS / ACTIVE_RUNS). A stale id left by a
  crashed/restarted run surfaces as None, so the poller never attaches a
  renderer to a dead stream. Additive field — existing consumers ignore it.

Frontend (`static/messages.js`):
- `_startHiddenActiveStreamPoll` / `_stopHiddenActiveStreamPoll` /
  `_attachServerInitiatedStream` implement the poll lifecycle. The poll fires
  one immediate tick on hide (so an in-flight turn is caught without waiting a
  full interval), attaches mid-flight turns via the reconnecting/replay path,
  and stops on re-show (the real SSE takes over), session switch, or once a
  stream is rendering.
- Started on BOTH hidden paths: the `visibilitychange` hook's hidden branch (a
  visible tab going to background) and `startSessionStream`'s hidden
  early-return skip (a session opened while already hidden). The visible path
  and `stopSessionStream` clear it.

Tests:
- `tests/test_hidden_tab_server_initiated_turn.py` — backend live-validation
  (stale id → None; STREAMS/ACTIVE_RUNS id returned) and frontend lifecycle /
  both-hidden-paths / replay-attach source locks.
- Widened the brittle fixed-width source-window slices in
  `tests/test_issue3996_sse_visibility.py` (1600/1700 → 2400) so the existing
  `visibilitychange` / hidden-skip assertions still find their markers after the
  poll-start lines were inserted into `startSessionStream`.
2026-06-29 16:28:30 +00:00
Rod Boev
343a8b50bf fix(#5139): distinguish offline gateway approval warnings 2026-06-28 17:53:37 -04:00
allenliang2022
265e176f6c address greptile P2s: stage counter starts at (1/N); brace-match test window
- messages.js: declare _retryDelays before the first setComposerStatus and show
  'Reconnecting… (1/4)' on the initial probe so the counter progresses
  (1/4)->(2/4)->(3/4)->(4/4) instead of jumping from a bare 'Reconnecting…' to
  (2/4) (looked like it started at 2).
- test: replace the fixed block_start+1600 char window with a brace-matched
  _reconnect_block() helper so the ordering / no-early-clear assertions cannot
  silently under-assert if the reconnect block grows. Add a source-lock for the
  (1/N) stage-counter start.
2026-06-29 01:16:53 +08:00
allenliang2022
8edbf48f32 fix(webui): stage SSE-error reconnect probes so the live turn doesn't blank out
After the chat EventSource fires 'error', attachLiveStream made a single 1.5s
reconnect probe and, if /api/chat/stream/status did not yet report active or
replay_available, fell straight through to _handleStreamError(): clearing the
owner INFLIGHT state, nulling S.activeStreamId, pushing a 'Connection
interrupted' message and re-rendering. That wiped the live message DOM even
when the backend was still producing tokens or the run-journal replay file was
a beat away from becoming visible, so the settled response disappeared and then
reappeared from sidecar/replay (or only on refresh).

Replace the single probe with a short staged retry window
(_retryDelays=[1500,3000,5000,8000] ms) driven by a recursive _probeReconnect.
Each stage re-queries stream status and reconnects/replays when the backend is
reachable; the live DOM and S.activeStreamId/INFLIGHT state are kept intact
across the whole window, and _handleStreamError is only reached after every
stage has failed.

Adds tests/test_sse_error_multi_probe_reconnect.py source-locking the staged
shape and the ordering invariant (terminal error only after the retry window is
exhausted; no inflight/activeStreamId clear mid-window).
2026-06-29 01:04:26 +08:00
nesquena-hermes
dbfa33a999 feat(moa): route /moa through WebUI via server-resolved MoA config (#5070, #5057)
Adds a dedicated GET /api/commands/moa/resolve endpoint + resolve_moa_config()
that reads the MoA preset/usage server-side. The /moa send-path passes only a
moa_config:true boolean to /api/chat/start; the server re-resolves the real
config itself (never trusts a browser-supplied dict) and threads it per-turn into
the live agent run. Fails closed (409) on gateway-backed sessions.

Replaces the originally-proposed broad 43-command subprocess approach (bounced)
with this narrow, security-reviewed /moa route. Maintainer fixes on top of the
contributor's converged design: moa_config is added to run_conversation only
when not None (older-agent TypeError safety) at the main call AND both auth
self-heal retry sites, and forwarded through the legacy-journal runtime adapter.

Gate: Codex SAFE (after the maintainer fixes) + full suite 10784 pass + the
activity-stream regression gate clean.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-28 00:23:51 +00:00
nesquena-hermes
e2ade27bfe fix(anchor): preserve settled mixed content order (#4958)
When a settled assistant turn carries _anchor_activity_scene and has mixed
content[] (text interleaved with tool_use), promote that ordering into anchor
scene rows (prose / tool / thinking rows in sequence) instead of only the raw
content[] fallback, and mirror the same scene model in the backend hydration
path so a cold reload reconstructs an identical transcript.

Also fixes: post-tool_use text/thinking in a NON-FINAL assistant message was
silently dropped — only the turn-final assistant's post-last-tool text is the
"final answer" tail; earlier assistant messages keep post-tool content as
activity rows. Tail thinking is excluded from the final-answer text but still
emitted as a thinking row (verified on both JS and Python paths).

Tool-row de-duplication is conservative — ID-equal always merges; name/
invocation matches are cardinality-gated and same-message; body-prefix is only
a secondary confirmation inside the id-flexible branch (also requires matching
started_at + compatible name + invocation). It biases toward an extra visible
tool card over ever silently merging two distinct calls.

Gate: Codex SAFE (tail-thinking edge verified already-handled both sides),
Opus SHIP (dedup fails toward duplicate-not-loss; non-final preservation, XSS,
non-mixed regression all clean), full suite 10778 passed (only the 4 known
pre-existing flakes). One non-blocking follow-up noted: bring the prose/thinking
peer-dedup into JS↔Python parity (cosmetic cold-reload extra-narration row).

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-27 22:54:29 +00:00
nesquena-hermes
3c493a9e37 fix(cancel): preserve partial turn payload via embedded cancel snapshot (#4647)
Cancelling a running turn briefly collapsed the already-streamed reasoning,
tool cards, and text to a bare "Task cancelled" marker before a follow-up
GET /api/session repopulated them — a visible race between the terminal
cancel SSE event and the refetch (#4076, #1361).

The terminal cancel event now carries a canonical, redacted session snapshot
captured under the per-session agent lock right after the cancelled turn is
persisted (including the recovered partial-assistant message). The frontend
applies that embedded snapshot immediately via _applyCancelSessionPayload and
skips the GET round-trip. The snapshot is a fully detached deep copy (redaction
rebuilds every container) so it cannot alias or be staled by concurrent writes;
it is only applied when its session_id matches the active view; and a
missing/failed snapshot degrades cleanly to the existing GET fallback.

The seven other cancel call sites are standardized via _cancel_event_payload()
(message + type + status), backward-compatible (no session key when none).

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-27 16:43:37 +00:00
b3nw
121ac7f253 fix: coerce msgContent to string in completion notification preview
Greptile P1 on #5036: block-array content can make msgContent return a
non-string; normalize before .replace() in preview helper.
2026-06-27 04:50:08 +00:00
b3nw
a84fc8e5c2 Fix completion notification preview to use settled final answer
The SSE done handler previously used assistantText.slice(0, 100), which
captures the start of the live-stream accumulator (early tokens and interim
updates) rather than the final answer shown after tools settle.

Add _completionNotificationPreviewText() using anchor final-answer projection
and settled message content, with a cleaned live-stream fallback. Hoist
lastAsst so background completions on non-active panes still get preview text.

Update PWA notification regression tests.

Closes #5035
2026-06-27 04:50:08 +00:00
Rod Boev
285f6a2408 fix(#4945): keep prose and partial table copies native 2026-06-26 17:51:31 +00:00
Rod Boev
8c09c8dfde fix(#4945): keep native copy for cell sub-selections 2026-06-26 17:51:31 +00:00
Rod Boev
0256b98a3e fix(#4945): sanitize mixed table selections during native copy 2026-06-26 17:51:31 +00:00
Rod Boev
c74b135b99 fix(#4945): preserve native copy fallback when clipboard data is missing 2026-06-26 17:51:31 +00:00
Rod Boev
63435e0523 fix(#4945): sanitize enhanced markdown table copy 2026-06-26 17:51:31 +00:00
nesquena-hermes
74cee77738 fix(approval): stale local approval card clears instead of dead-ending (#4948 local variant, #4771 follow-up)
On the default local in-process backend, an approval card whose stream
ended while still pending (cancel/fork/provider-error/completion) left the
agent queue entry dropped and the _pending mirror reconciled away. Clicking
Approve/Deny then sent an approval_id that matched nothing, so the handler
returned a bare {ok:false}; since #4771 the frontend surfaces that as
'Approval response not accepted.' with a stuck card (reported by
@santastabber on v0.51.666 and b3nw).

Distinguish a genuinely stale card (no pending approval for the session ->
benign {ok:true, stale_cleared:true} so the UI clears the orphan) from a
stale-id click made while a DIFFERENT approval is live (still ok:false so it
can never resolve the wrong command -- #527 guard preserved). Frontend
clears the orphan card on stale_cleared even if the displayed id drifted.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-26 06:46:22 +00:00
nesquena-hermes
12d1cec90a fix(#4930): don't render a fabricated turn duration from a stale pending_started_at
A settled turn that recorded no live duration fell back to
(Date.now()/1000) - session.pending_started_at. But pending_started_at is the
start of an IN-FLIGHT turn, so for a settled turn it is either stale (a session
that sat idle) or about a different pending turn — rendering a bogus
'Processed 15h 32m' on fresh conversations (#4930). Gate the fallback on a turn
actually being in flight (active_stream_id || pending_user_message); otherwise
return undefined (no duration shown) rather than a fabricated one. The recorded
live duration (_turnDuration / base.turn_duration) still takes priority, so this
only removes bogus durations, never a correct one.
2026-06-25 19:53:19 +00:00
Rod Boev
7d617d91f7 fix(#4771): block duplicate approval submits 2026-06-25 13:09:42 +00:00
Rod Boev
0b12b3b1fb test(#4771): cover both approval rejection paths 2026-06-25 13:09:42 +00:00
Rod Boev
528fa72390 fix(#4771): keep gateway approval failures actionable 2026-06-25 13:09:42 +00:00
nesquena-hermes
89d7fae347 fix(#4622): restore full body for CAPPED-PREVIEW settled snippets, not just empty
Codex gate caught that the enrich only restored when the settled snippet was
fully EMPTY — but the backend persists a 4000-char capped preview
(_TOOL_RESULT_SNIPPET_MAX), so long terminal/tool output settles to a truncated
4000-char prefix, not empty (the actual #4622 symptom). Now also restore when
the settled snippet is a bounded preview of the live one (live longer + settled
is a prefix + settled length >= the 4000 cap), without clobbering a genuinely
short non-preview value. Adds capped-preview restore + short-prefix-not-clobbered
tests (proven non-vacuous).
2026-06-25 06:51:33 +00:00
nesquena-hermes
8d2d8b1f98 fix(#4622): trim dead enrichment branches per Opus gate
Opus + Codex both SAFE TO SHIP. Opus flagged two cosmetic redundancies (not
blockers): the snippet guard checked payload.result/payload.output which
_anchorSceneToolRowFromCall never sets, and the tool.result/tool.output writes
are never read by the consumer _anchorSceneToolCallFromRow (it reads
tool.snippet||payload.snippet||payload.result||payload.output). Removed both —
tool.snippet/payload.snippet is what actually restores the visible body.
Behavior-identical; 18 settled/reconnect tests still green.
2026-06-25 06:40:27 +00:00
nesquena-hermes
ef3092434f fix(#4622): restore tool-card result body on the settled rebuild path
In the Transparent Stream / Worklog view, terminal tool cards lost their stdout
and patch/edit cards lost their diff AFTER a turn settled (live cards were fine).

Root cause (proven via a node harness driving the real settled chain
_anchorSceneRowsByMessageIndex -> _anchorSceneToolRowFromCall ->
_anchorSceneToolCallFromRow): the settled rebuild builds each tool row from the
persisted messages[].tool_calls (state.db/sidecar), which can carry only a short
preview — or, on a cold/paginated load, nothing — for the result body. The full
output lives on the live S.toolCalls entry at settle time, but the dedup loop in
_anchorSceneRowsByMessageIndex SKIPPED the matching live entry (continue) instead
of restoring the missing body onto the surviving settled row. So buildToolCard
got tc.snippet='' -> empty output region, and the patch/edit diff (detected from
the snippet) never rendered. This also blanks the transparent 'Output' tab and
the 'Full' (detailed-input) tab.

Fix: new _enrichSettledToolRowBodyFromLive() restores snippet/result/output,
command, and args onto a settled row from the matched live call, ONLY when the
settled value is empty (never clobbers a genuinely persisted body). Wired into
the dedup loop: on a tid match that would have been skipped, enrich the existing
pooled row instead of dropping the live data.

The prior #4625 guard only exercised the LIVE buildToolCard path, which is why it
stayed green while the settled path regressed. New
tests/test_issue4622_settled_toolcard_body.py drives the full SETTLED chain and
asserts: terminal stdout restored + full output in data-full + transparent Output
tab non-empty; patch diff body restored + renders as a diff + Full-tab args kept;
genuine persisted body not clobbered; args restored when dropped; no-live-match
leaves the row untouched; no duplicate row after enrich. Verified to FAIL on clean
master (assertions) and pass with the fix. Also registered the new helper in the
and now references the new symbol).

Reusable proof harness saved to ~/WebUI/qa/harnesses/.

Closes #4622.
2026-06-25 06:40:27 +00:00
nesquena-hermes
0a6947c2d2 fix(#4754): namespace dismissed approvals by session (gate finding)
Codex CORE: the dismissed-set was keyed by bare approval_id, but gateway/run
sources preserve externally-supplied IDs without session namespacing — so two
sessions carrying the same approval_id collide and dismissing in session A hides
session B's still-pending approval. Fix: _approvalDismissKey(sid, approvalId)
builds a session-namespaced composite key, threaded through _isApprovalDismissed,
_markApprovalDismissed, _unmarkApprovalDismissed + all 5 call sites (showApprovalCard,
dismissApprovalCard, respondApproval, no-pending poll cleanup). Updated 4 stale
assertions to the namespaced signatures + added a functional node-driver
cross-session-collision regression (proven non-vacuous).

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-25 02:15:47 +00:00
Rod Boev
0258599a46 fix(approval): clear pending attention state on dismiss (#4754) 2026-06-25 02:04:51 +00:00
Rod Boev
820362ca14 fix: scope un-dismiss to session-local pending, not global _approvalCurrentId 2026-06-25 02:04:51 +00:00
Rod Boev
2956804a14 fix(#4754): persist approval card dismissal across tab switches and restarts 2026-06-25 02:04:51 +00:00
Rod Boev
36742abcee fix(#4875): widen continuation-prompt filter to cover output-length and tool-call variants 2026-06-25 01:21:08 +00:00
Rod Boev
f43bf2df6a docs(#4856): update stale call-site comment to match overflow-anchor semantics 2026-06-25 01:06:09 +00:00
nesquena-hermes
3c8cfdc589 fix(#4811): regenerate row_id/seq from final order index so anonymous tool rows don't collide (gate finding)
Codex gate found a SILENT drop: reconnect scene rows are built with orderIndex=0,
so two tools WITHOUT a tool id at the same message index both got row_id
'...:tool:<idx>:0'; _completeSettledAnchorSceneForTurn dedupes by row_id, silently
dropping the second. Now the emit loop rewrites order_index AND regenerates the
index-derived row_id + seq + identity.seq from the final per-bucket position
(tid-based tool row_ids, already unique, are left untouched). Regression test
added + proven non-vacuous.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-24 19:48:56 +00:00
Rod Boev
f330fa7f44 perf(reconnect): pre-index toolCalls and remove unreachable guard (#4811) 2026-06-24 19:39:40 +00:00
Rod Boev
057b878136 fix(#4811): sort reconnect scene rows by phase and started_at chronology 2026-06-24 19:39:40 +00:00
Brett Yelverton
2a40ca4240 fix(streaming): restore mobile scroll-jank guard during live renders
A later streaming parse-cache change dropped the #MOBILESCROLL guard from
the live-stream render tick while leaving the helper and CSS in place.
On iOS PWA, Safari can paint a transient scrollTop=0 frame during streamed
DOM rebuilds, forcing the reader to the first/oldest message.

Re-add _fixMobileScrollJank() before streaming DOM work begins and pin it
with a regression test so the third layer of the mobile scroll fix cannot
be silently dropped again.
2026-06-24 17:20:40 +00:00
nesquena-hermes
d68afd7862 fix(#4800): key the streaming parse-cache on liveReasoningText too (Codex gate)
The parse-result cache keyed only on assistantText, but _parseStreamState() is a
function of BOTH assistantText AND liveReasoningText. A 'reasoning' SSE event (which
mutates liveReasoningText without calling _scheduleRender) landing between a token's
_scheduleRender(parsed) and the rAF firing made _doRender serve a parsed computed from
the older reasoning -> _renderLiveThinking could show stale thinking text. Keying the
cache on liveReasoningText too forces a fresh _parseStreamState when reasoning changed,
and also makes a canceled-then-rescheduled render miss the stale entry. Co-authored-by: wlknight
2026-06-23 22:52:51 +00:00
nesquena-hermes
1dbff4e708 Merge branch 'peek-4800' into gate/4800-stream-perf 2026-06-23 22:46:14 +00:00
nesquena-hermes
e26a6e47c0 Merge branch 'peek-4797' into stage/rodboev-4797-4796 2026-06-23 22:19:12 +00:00
wlknight
20a900be60 fix(perf): eliminate O(DOM) streaming render freeze on long agent answers
Introduce _safeSmdRenderer — a renderer wrapper that hooks smd's set_attr
to validate URL schemes inline as each DOM node is created, eliminating
the per-token _sanitizeSmdLinks(assistantBody) full-DOM scan that caused
progressive freezing on answers of 30,000+ chars (5,000+ DOM nodes).

Also add parse-result caching to _scheduleRender to avoid redundant
_extractInlineThinkingFromContent scans between the token event handler
and the rAF render callback.

Changes:
- New _safeSmdRenderer(el): URL scheme safety inline via set_attr hook
  (identical algorithm to proven _streamFadeRenderer.set_attr, no animation)
- _smdNewParser: non-fade path uses _safeSmdRenderer instead of bare
  default_renderer
- _smdWrite: removed _sanitizeSmdLinks(assistantBody) call — safety now
  handled by renderer's set_attr at node-creation time
- _scheduleRender(parsed): accepts optional cached parse result from
  token handler to avoid duplicate O(n) text scans
- Token handler: passes _parseStreamState() result to _scheduleRender

_sanitizeSmdLinks is preserved at _smdEndParser (stream-end safety net)
and in fallback paths where smd is not available.

All 166 existing tests pass.
2026-06-24 06:09:44 +08:00
Rod Boev
94e25ad494 fix(#4753): notify background desktop tabs without hiding streams 2026-06-23 17:10:03 -04:00
Frank Song
4c8e88ab2d fix: dedupe live compact worklog process prose 2026-06-23 23:03:04 +08:00
Frank Song
a4470bd30c Deduplicate live progress reasoning echoes 2026-06-23 12:51:16 +08:00
nesquena-hermes
20c70c3505 fix(#4730): clear assistantBody before smd re-init to prevent duplicate live text
Codex gate found: when window.smd becomes available after a segment already
rendered through the renderMd() fallback, _smdNewParser resets _smdWrittenText
to '' but does NOT clear the element, so the following _smdWrite(displayText)
appends the full accumulated segment on top of the existing fallback render —
duplicating the live assistant text. Clear assistantBody.innerHTML before re-init.

Co-authored-by: akrhin <akrhin@users.noreply.github.com>
2026-06-22 23:27:23 +00:00
sintez
f2abe50b7c perf: skip gateway poll when hidden/streaming, re-init smd parser before innerHTML fallback 2026-06-23 02:13:34 +03:00