The full renderMd() pipeline maps MEDIA:<path-or-url> tokens to inline
images / video / audio / SVG / PDF / etc. via stash + restore passes.
The streaming smd renderer used by the live assistant body never went
through that pass, so any MEDIA token emitted mid-turn showed up as the
raw path string in the chat window and only became a real <img> once
the turn settled and the full render fired.
Fix:
- Extract the URL/media → HTML mapping in renderMd() into a shared
helper _inlineMediaHtmlForRef(ref, sessionId) so the streaming path
can reuse the same DOM the settled message will eventually render.
- Add _smdMediaAwareAddText in messages.js that wraps smd's add_text
to detect MEDIA tokens in any incoming text chunk and splice the
resulting <img>/<video>/<a>/<div data-path=...> node directly into
the smd parent via a DOMParser-created fragment. Plain prose still
delegates to the renderer it intercepted (so the fade renderer keeps
its word-by-word animation, the safe renderer keeps its O(n) plain
text-node insertion).
- Route _safeSmdRenderer.add_text and _streamFadeRenderer.add_text
through the interceptor. Each fast-paths the no-MEDIA case to the
original baseAddText so we can't recurse into our own wrapper.
Coverage:
- New test_smd_media_in_stream.py locks in the architectural invariants
(helper exists, both renderers delegate to it, recursion guard).
- test_renderer_js_behaviour.py driver now provides a minimal
_inlineMediaHtmlForRef stand-in so renderMd can be invoked standalone
under node without the rest of ui.js in scope (matches the patterns
renderMd already relied on for _IMAGE_EXTS / _SVG_EXTS / _AUDIO_EXTS).
- test_svg_audio_video_rendering.py static-count assertions relax from
">=2" to ">=1" since audio/video/SVG markup now lives in a single
shared function rather than being copy-pasted inside renderMd's
stash-restore closure.
* fix(transparent-stream): defer settled tool-detail DOM + cap per-turn rows (#5966)
Reported by @curtisszmania-cytocync.
Transparent Stream built a DOM node for every activity event of every settled
turn at load, and eagerly materialized each tool row's full detail body — a
15+ prompt reasoning-heavy history produced thousands of subtrees and tipped the
tab into a multi-GB freeze. The #5860/#5839 lazy-worklog fix did not cover
Transparent Stream (it never collapses).
Two-part surgical fix in static/ui.js, mirroring #5860's proven machinery:
- Row-detail deferral: a settled+collapsed transparent tool row renders
header-only; its heavy .tool-card-detail body builds on first expand
(_materializeTransparentToolDetail), recovering the tool call from the scene
after an innerHTML cache round-trip. Same anchor-suppressed post-processing as
the eager path, so an expanded row is byte-identical.
- Per-turn row cap: a settled turn with >CAP+SLACK (30+10) rows renders the last
30 behind a clean 'Show earlier steps (N)' affordance that materializes the
prefix in place with viewport compensation. Just-settled turn (keep-open token)
and already-revealed turns are exempt; true Trace tool-count is stashed so the
label stays honest; Expand-all reveals capped rows; cache-restore re-wires the
affordance. Live rendering is untouched.
Server-side #5858 (scene serialize-after-messages) already covers the container
RSS half on the experimental channel; this is the browser-DOM half.
* style(#5966): compact 'Show earlier steps' pill (hug content, left-aligned)
Constrain the affordance to fit-content width + align-self:flex-start so it
reads as a light inline control on the activity rail, not a full-width bar.
* fix(#5966): address Codex gate findings + Fable UX fast-follows
Codex SHIP-ONLY-WITH-FIXES (3 SILENT defects), all fixed + browser-verified:
- F1: buildToolCard PRE-BUILDS .tool-card-detail when a tool has args/output, so
the old `!detail` defer guard skipped exactly the heavy rows. Defer now fires on
settled+collapsed+has-detail and STRIPS any prebuilt body (verified: 400 shell
tool rows -> 0 detail bodies mounted; expand still materializes byte-identically).
- F2: stamp owner rawIdx on rows + affordance; dataset recovery and cache-restore
rebind resolve the scene-owning message (multi-segment turns own it on a later
segment, not the first) with a scene-owning-segment fallback.
- F3: persist revealed-turn state in a session/owner-keyed Set + invalidate the
session HTML cache on reveal, so a rebuild/switch-away never re-caps a turn the
user expanded (verified: reveal 30->400, full re-render stays 400).
Fable SHIP-UX fast-follows: t()-with-fallback label (+ en locale keys), 640px
touch breakpoint, stale comment fixed.
Tests: 21 pass (added has-detail deferral+strip, detail-less no-defer, owner-idx
recovery, persistent-reveal, i18n).
* fix(#5966): address Codex re-gate findings (r2)
Codex re-gate SHIP-ONLY-WITH-FIXES — 2 more SILENT defects, both fixed + browser-verified:
- F1(r2): _materializeTransparentToolDetail rebuilt via the thinner
_transparentToolDetailHtml(), dropping buildToolCard's richer detail (diff
coloring / show-more / canonical shell-command lead). Now transplants
buildToolCard(tc)'s own .tool-card-detail node so an expanded deferred row is
byte-identical to the eager path (verified: canonical structure + 595-char body).
- F2(r2): after an in-session renderMessages() rebuild (next send) an already-open
settled tool row got re-deferred, and disclosure-restore only toggled .open →
open-but-empty card. _setWorklogDetailDisclosureOpen now materializes a deferred
transparent row BEFORE toggling open (verified: expand → rebuild → still open
WITH content). Likely the same defect the RegressionGate caught intermittently.
Tests: 22 pass (added canonical-detail + disclosure-restore-materialize).
* fix(review): add show_earlier_steps i18n keys to all locales (#5966)
The transparent-stream row-cap "Show N earlier steps" pill added two new
i18n keys (show_earlier_steps, show_earlier_step_one) to the English block
only, so the strict locale-parity tests (zh/ko/ru/zh-Hant/...) failed with
missing-key assertions and CI shard 0 went red across 3.11/3.12/3.13.
Add both keys to all 14 non-English locale blocks with the English string as
fallback (the repo's established add-with-English-fallback convention for new
UI keys). Inserted after each block's collapse_all anchor. All 197 locale
tests + the #5966 suite green; node --check clean.
* fix(review): extract _transparentToolRowHasDetail into the #5700 harness
#5966 added a _transparentToolRowHasDetail() call into the settled transparent
render (_anchorSceneTransparentNodeForRow) to decide whether to defer a
collapsed tool row's detail body. The pre-existing #5700 timestamp test extracts
that render fn into a node harness but didn't extract the new helper, so the
extracted fn hit a ReferenceError → node exited non-zero → 3 #5700 tests failed
(RED across CI shards; the PR updated its own #5966 harness but missed this one).
Add `eval(extractFunc('_transparentToolRowHasDetail'))` to the harness. The
helper self-guards its _toolActionKind/_toolCardAllowsDetail deps (returns true
when absent), and the #5700 timestamp lives on the row HEADER (rendered even when
detail is deferred), so all 20 #5700 assertions pass unchanged — confirming this
was a harness gap, not a timestamp regression.
* fix(#5974): hide Show-earlier-steps pill when a capped transparent turn is collapsed (Fable UX gate)
Fable UX gate SHIP-WITH-UX-FIXES: the .transparent-earlier-steps pill wasn't in the
.assistant-turn[data-transparent-turn-collapsed=1] hide rule, so collapsing a capped
turn via the name-tag toggle left an orphan pill. Added it to the hide rule.
(i18n finding was already resolved — both pill keys present in all 15 locales w/
English fallback + show_earlier_step_one is live at ui.js:12918, not dead.)
* Release exp-v0.52.50: transparent-stream lazy settled DOM + per-turn cap (#5974, #5966)
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix(#4685): meter post-compression model context
* Release exp-v0.52.48: meter post-compression model context (#5872, #4685)
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* 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>
* 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>
Salvaged from #4627 by @perejaslav. Core Hermes already supports
reasoning_effort=max; this closes the WebUI parity gap (Anchor-A) — adds
max to VALID_REASONING_EFFORTS + dropdown/chip/reasoning-command, keeps
the degrade ladder for capped models.
Co-authored-by: perejaslav <perejaslav@users.noreply.github.com>
Two non-blocking UX improvements from the Fable gate on #5739:
1. Full date+time tooltip (title attr) on the worklog event-time label — the bare
clock is date-ambiguous when a settled session is reviewed later or a run crosses
midnight. New _activityFullClockLabel (range-guarded so a bad epoch never yields
'Invalid Date' in the tooltip) + test.
2. Hide the time label under 420px — its ~50px of fixed chrome squeezes the
collapsed row's flexible preview toward zero on phones.
Co-authored-by: rodboev.
Two gate findings on the worklog-event-timestamp feature (#5700/#5739):
1. Thinking-row timestamps disappeared after settlement. Reasoning source events
carry no server timestamp; the live DOM showed a fallback time but the settled
scene row rebuilt with created_at:null. Now stamp created_at on the source event
the first time it's seen (messages.js apply path) and carry row.created_at
through snapshot hydration, so the timestamp survives live->settled.
2. A garbage huge numeric timestamp (e.g. 1e20) passed the finite/>0 checks and
rendered literal 'Invalid Date'. _timestampSeconds now rejects epochs outside
JavaScript's valid Date range. Added a regression test.
Co-authored-by: rodboev. Gate findings from adversarial Codex re-gate.
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>
Codex gate CORE: /api/updates/apply + /force ignored the request-body channel
and re-read the saved setting, so a channel switch whose debounced autosave
hadn't landed could apply the OLD channel. The banner now sends the channel the
CHECK reported for each target; the endpoints validate it against the enum and
thread it into apply_update/apply_force_update (None → saved-setting fallback,
preserving prior behavior). Agent stays channel-neutral server-side.
When transcript virtualization is disabled (the #4325 opt-out,
_virtualizeTranscript===false), renderMessages() renders every row with no
windowing and never runs the virtualized measure pass
(_updateMessageVirtualMeasurements early-returns when !virtualized). Under
@media (pointer: coarse), .msg-row[data-role="user"] carries
content-visibility: auto; contain-intrinsic-size: auto 96px. Every rebuild does
inner.innerHTML='' then recreates rows as fresh elements, so a fresh off-screen
tall user row (a long paste measuring thousands of px) reserves only the flat
estimate instead of its real height. scrollHeight shrinks by (realHeight -
estimate), the browser force-clamps scrollTop, and the viewport jumps backward
(a browser clamp, JS=none, so scrollTop-write compensation cannot catch it).
#5638 fixed this for the virtualized wipe-and-rebuild path but left the
non-virtualized full-rebuild path uncovered.
Fix, three coordinated parts:
- _estimateUserRowIntrinsicHeight weights CJK / full-width characters as ~2
columns (they wrap at ~24 chars/line, not 48), so a CJK paste reserves close
to its real height even before it is ever measured.
- _applyUserRowIntrinsicHeight reserves max(remembered, estimate): a remembered
height can be a partial paint (a row taller than the viewport only paints its
intersecting slice under content-visibility:auto), so the estimate floors it.
- _rememberRenderedUserRowIntrinsicHeights, called pre-wipe inside
renderMessages, reads the still-laid-out rows' real heights and persists them
keyed by session-relative index, only for rows within the viewport (a fully
off-screen never-painted row reports its collapsed reserve and must not poison
the map), floored at the estimate.
Desktop rests at content-visibility:visible so intrinsic-size is inert there;
verified no behavior change with pointer:fine.
Adds tests/test_issue5744_nonvirtual_userrow_collapse_jumpback.py (7
mutation-checked node-harness tests). Existing #5637/#5638 suites and the
render/virtualization suites pass.
When the anchor row is gone and a virtual top-spacer is present but the snapshot
carries no captured topPadBefore (null), Number(null) is 0, so the topPad-delta
branch would add the ENTIRE current spacer height to scrollTop and fling the reader
far from their content. Guard on an actual (non-null) captured topPadBefore; else
keep the raw fallback target. Mutation-checked (drop the null guard -> the reader is
flung 1300px and the test fails).
Round-2 gate-cert proved the round-1 'snapshot.top + delta' + nearest-to-current
arbiter is mathematically unwinnable: a row's rect offset is scroll-relative
(rect.top - containerRect.top = rowContentPos - scrollTop), so a staged absolute
target only equals the true content-hold when scrollTop == snapshot.top — and in
that exact case the arbiter measures |raw - cur| = 0 and picks raw, writing the
stale value (the reported up-jump, unfixed).
Rework to the app's own realign idiom (mirrors _restoreMessageViewportAnchor and
_compensateScrollForMeasurementDelta): when a measurable anchor row exists, write
el.scrollTop += (currentOffset - capturedOffset) from the LIVE scrollTop — a no-op
when already aligned, a heal when not, no arbiter. Restore the per-tier lookup
guard (key -> sessionIdx, never the rawIdx degradation that maps to a different
message after a virtualization re-window). Genuinely-gone anchor: mirror the
topPad-delta idiom the snapshot already carries (topPadBefore), else keep raw.
Gate-cert tests now use scroll-dependent rect mocks (rect.top = rowContentPos -
scrollTop) so certified hold numbers are physically realizable; mutation-checked
(revert to raw -> the hold tests fail; drop topPad-delta -> that test fails).
Frontend fix for the model-picker provider-contamination vector of #5567 (the
backend HERMES_HOME vector shipped in #5577). Credit @b3nw for the root-cause trace.
_modelStateForSelect(sel, modelId) resolved the provider from sel.selectedOptions[0]
(the dropdown's CURRENTLY-selected option) instead of the option whose value matches
modelId. During a profile/tab switch or a model-list rebuild the dropdown transiently
still holds the PREVIOUS profile's default option (e.g. an ollama model), so the wrong
provider was stamped onto a model it doesn't own, persisted into the session's
model_provider, and (since _modelProviderForSend reads the stored value first) re-sent
on every turn -> 'Provider ollama...no API key' for a provider the session never used.
Fix: resolve the provider from the option whose VALUE === modelId; trust the
currently-selected option only when its value matches (preserving the user's exact
pick in the same-value/different-provider collision case). A genuinely-absent model
yields a null provider so the backend re-infers.
This is the WRITE-site fix — it stops all NEW contamination (new-session + send paths
route through _modelStateForSelect). Both Codex and Fable reviewed it SAFE.
Repairing sessions ALREADY poisoned before this shipped is deliberately deferred to a
separate follow-up: a 3-round Codex+Fable gate proved a frontend load-time repair that
reads the shared #modelSelect DOM is a cross-session race surface (it re-manufactured
the reverse-contamination it aimed to fix), and it can't reliably heal live-only-model
sessions anyway. The robust place for that repair is the backend chat-start boundary,
where session + profile identity is unambiguous. Already-poisoned sessions self-heal on
their next healthy send / manual re-pick in the meantime.
Tests: node-behavioral resolver scenarios (tab-switch race, same-value collision,
missing-model null, match-by-value, default->null) + the unrelated-selected-option
case from #5727. Partially addresses #5567 (write-vector); follow-up tracks the
already-poisoned-session repair.
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
Address round-2 gate cert (issuecomment-4896321817) on sha:dcec232d:
1. BRICK (Codex strace-verified): v2's fcntl-flock holder check on
.git/index.lock was wrong. Codex straced git 2.43.0 and proved
that git uses O_CREAT|O_EXCL + rename(2), NOT advisory locking,
so an fcntl-flock probe returns False for a lock file a live git
process is holding open. The 'fail-closed' claim was wrong:
auto-delete could still race and corrupt the index.
2. CORE (Codex): if .git/index.lock vanished between _is_lock_held
and os.remove, the FileNotFoundError surfaced as a 'remove-failed'
diagnostic instead of 'already gone' and prevented the apply retry.
Fix: stop deleting lock files from the server entirely. The only thing
that removes a lock is the user, on the host, via the manual command
surfaced in the response. Once the lock is gone, the user re-clicks
Update Now and the normal non-destructive apply path runs.
Implementation:
api/updates.py:
- Removed _is_lock_held (fcntl-flock probe) -- was provably unsafe.
- Removed _try_remove_lock (os.remove path) -- was provably unsafe.
- Removed _GIT_LOCK_FILES_REMOVABLE (no longer enumerable by name).
- Added _inventory_locks(path) -> dict: pure inventory of well-known
+ other lock files under .git/. Never touches anything.
- Rewrote apply_clear_lock to inventory-only + manual-instruction.
When .git/index.lock is absent, runs the normal non-destructive
apply path; when present, returns ok=False with the exact 'rm -f'
command + an explanation of why the server cannot do this safely
(O_CREAT|O_EXCL cannot be detected with advisory probes).
CORE-1 race evaporates: there is no os.remove to race against.
api/routes.py:
- Updated the /api/updates/clear_lock comment to reflect v2.2.
static/ui.js:
- applyClearUpdateLock: when res.lock_held, call new
_renderLockManualInstruction(target, res) which surfaces the exact
'rm -f <path>' command in a copyable code block plus an
'I've removed the lock -- retry update' button that POSTs the
same endpoint again; the second call takes the success branch
and re-runs the normal apply path. Also lists any other lock
files present so the operator can investigate.
tests/test_updates.py:
- Removed all 4 tests for the deleted v2 helpers (_is_lock_held
Returns*, _try_remove_lock*).
- Added test_v2_probe_helpers_removed: regression guard that
fails loud if either helper is ever reintroduced.
- Rewrote test_apply_clear_lock_removes_unheld_lock_and_runs_normal_update
and test_apply_clear_lock_refuses_when_lock_held for the v2.2
contract. The 'refuses' test monkeypatches os.remove to record
any deletion attempt and asserts the call list is empty -- a
strict runtime guard against future regressions to auto-delete.
- Added 7 new tests: test_inventory_locks_* (4), test_apply_clear
lock_with_no_lock_runs_normal_update, test_apply_clear_lock_with
lock_present_returns_manual_instruction, test_apply_clear_lock
listing_includes_other_locks, test_apply_clear_lock_rejects
unknown_target, test_apply_clear_lock_rejects_not_git_repo.
Verification:
./scripts/test.sh tests/test_updates.py -> 73 passed
./scripts/test.sh tests/test_update_banner_fixes.py -> 102 passed
./scripts/test.sh tests/test_api_timeout.py -> 8 passed
python3 scripts/ruff_lint.py --diff upstream/master -> 0 new violations
Full ./scripts/test.sh -> 12137 passed, 18 failed (all 18 pre-existing
flakes in unrelated subsystems; verified not v2.2 regressions)
Address the RED gate cert on PR #5688 (2 BRICK + 1 CORE) and the
incoming Greptile review (1 P1 + 3 P2):
- BRICK-1 (race-safety): drop the mtime heuristic and any age-based
lock removal. Add /api/updates/clear_lock with a fail-closed
fcntl.flock holder probe (refuses if any process still holds the
file, on POSIX). On non-POSIX, fails closed. Touches only
.git/index.lock (the only well-known short-lived lock git creates).
- BRICK-2 (destructive path coupling): remove lock_conflict from the
force-button condition. Add a separate btnClearUpdateLock that calls
/api/updates/clear_lock -- never apply_force_update. The recovery
path never runs checkout/clean/reset --hard.
- CORE-1 (unconditional pre-cleanup): apply_force_update no longer
iterates .git/**/*.lock. Lock cleanup is exclusively the clear_lock
endpoint's job.
- Greptile P1: when a pull-lock error fires after a stash was pushed,
run git stash pop (with apply+drop fallback) so the user's local
modifications aren't stranded in the stash silently. v2 tests
cover the stashed and the clean cases.
- Greptile P2 (broad 'lock file' substring): tightened _GIT_LOCK_SIGNATURES
to specific git error strings ('index.lock': file exists, '.lock':
file exists, 'another git process seems to be running',
'unable to create .git/index.lock') so unrelated ref-transaction
lock-loss messages no longer trigger a lock-conflict response.
- Greptile P2 (redundant git_dir.exists / magic number): both gone as a
side effect of removing the apply_force_update cleanup loop entirely.
Refactor: rebased onto current upstream/master so PR is no longer
'behind' 130 commits.
Tests:
- 14 new v2 tests covering holder probe (true/false/NOGIL probe),
_try_remove_lock (refuse-on-held/success-on-unheld),
apply_clear_lock (success/refused), signature set parameter table
including false-positive class, apply_force_update no-touch
contract, pull-lock stash restore, pull-lock no-stash-when-clean.
- 2 v1 tests removed (they encoded the unsafe behavior).
- 2 v1 parametrize rows dropped (their positive cases relied on the
broad 'lock file' substring).
Closes#5687
- Detect lock file errors (lock_conflict flag) on fetch/status/pull
failures in _apply_update_inner (#1)
- Expose Force Update button on frontend when lock_conflict is
received (#3)
- Proactively clean stale lock files (>30s old) in apply_force_update
using rglob discovery instead of a hardcoded path list (#2)
- Add 10 tests: _is_git_lock_error parametrized unit tests,
_apply_update_inner lock path coverage, and apply_force_update
stale/recent lock behavior
The renderMessages() re-anchor call `queueMicrotask(()=>_reanchorPinnedTailAfterRender(...))`
threw `ReferenceError: _reanchorPinnedTailAfterRender is not defined` in the standalone
renderMessages() node harness (test_anchor_fallback_ownership), which evals the extracted
renderMessages body with only a minimal set of stubbed helpers.
Guard the call with `typeof` checks on both `queueMicrotask` and
`_reanchorPinnedTailAfterRender`, mirroring the existing
`if(typeof _deferClearProgrammaticScroll==='function')` guard a couple of lines below, so
a harness that evals renderMessages without defining these helpers does not throw. In the
browser both are always defined, so the fix path is unchanged.
Add a source-lock asserting the guard so a future refactor can't drop it and re-break the
harness.
A pinned/tail-following reader gets a fast ~1-row back-and-forth bounce on every
mid-stream re-render (tool completion, activity-scene refresh, clarify echo).
renderMessages() rebuilds the transcript (inner.innerHTML='' then re-append). For a
pinned reader the post-render path writes scrollTop=scrollHeight while still INSIDE
the render sync stack, where the browser reports a transient scrollHeight a few px
short of the settled value (layout is batched). scrollToBottom lands scrollTop a
little high (short of the true tail); that intermediate is painted this frame and the
settle rAF corrects it the next frame -> a ~1-row bounce (measured ~82px, confirmed
with a per-frame scrollTop flight recorder and an isolated CSSOM A/B).
Fix: capture the pre-wipe near-tail state (geometry, since the wipe's clamp scroll
event can perturb the closure pin flags) and, at the end of renderMessages, schedule
_reanchorPinnedTailAfterRender in a microtask. The microtask runs after the sync stack
flushes layout to the settled height but before paint, so writing the settled max
lands the tail exactly and the short intermediate never reaches the screen. A
synchronous call reads the same transient short height and is a no-op (verified: sync
leaves the 82px bounce; microtask drives it to 0). Only re-anchors a pre-wipe
tail-follower left short of the settled max, so an unpinned reader parked in history
is never moved (verified 0px displacement); arms the existing _programmaticScroll
latch; idempotent.
Tests: test_pinned_tail_midstream_jitter.py runs the real extracted helper in a Node
harness (re-anchor short tail-follower / never move a history reader / idempotent
no-op / mutation check / source locks on the queueMicrotask call site and settled-max
write). Neighboring scroll suites remain green.
Address greptile review: the new S.activeStreamId restore branch omitted the
_maybeShowNewMessageScrollCue call that the preserveScroll and idle-unpin restore
branches both make. An unpinned reader whose position is restored mid-stream should
still see the new-message cue, so add it for consistency. Update the behavioral test
to assert the cue fires and the test_issue1690 source-lock for the new branch body.
renderMessages() rebuilds the transcript via inner.innerHTML='' then re-appends
rows. That wipe collapses #msgInner scrollHeight toward the empty-table height, so
the browser is forced to clamp #messages.scrollTop down to the new near-zero max --
a browser primitive (no JS writes scrollTop; overflow-anchor does not govern it).
When this rebuild happens mid-stream while a reader has scrolled up into history
(_messageUserUnpinned), _scrollAfterMessageRender took the S.activeStreamId branch
and called scrollIfPinned(), which is a no-op for an unpinned reader -- so the
clamped-to-0 scrollTop was never restored and the reader was stranded at the top.
Fix: in that branch, when the reader is unpinned and a pre-wipe scroll snapshot
exists (renderMessages already captures one whenever _messageUserUnpinned), restore
the snapshot instead of the no-op. Pinned / tail-following readers keep
scrollIfPinned() so live-follow is unchanged. Reuses the existing
_restoreMessageScrollSnapshot helper (same one the preserveScroll and idle-unpin
branches use), so no new core-timing risk.
Tests: test_wipe_rows0_streaming_unpinned_restore.py executes the real extracted
_scrollAfterMessageRender against a fake #messages + instrumented restore stubs and
asserts the unpinned+activeStreamId+snapshot case restores (scrollTop returns to the
pre-clamp value) while pinned / no-snapshot cases fall back to scrollIfPinned();
mutation-checked (reverting to the bare scrollIfPinned() fails the behavioral asserts).
Updates the test_issue1690 source-lock for the new branch structure.
The module-level _userRowIntrinsicHeightBySessionIdx cache is keyed by
session-relative index (_messageSessionIndexForRawIdx = _messageSessionIndexBase()
+ rawIdx). The base is 0 for the common non-offset session, so keys collide across
sessions: after switching from a session with tall user rows to one with short
rows, the new session's off-screen rows at colliding keys inherited the previous
session's remembered heights and inflated scrollHeight until re-measured.
Add _clearUserRowIntrinsicHeightCache() and call it from
_clearMessageVirtualHeightCache — the existing per-session height-cache clear that
_resetMessageRenderWindow invokes on session switch — so the new cache shares the
exact same lifetime as _messageVirtualHeightCache. Keys are deleted in place to
keep the const binding stable. Guard the call with typeof so the node harnesses
that extract _clearMessageVirtualHeightCache without this helper keep running.
Adds a mutation-checked regression (test_cache_cleared_on_session_switch_prevents_
stale_height_bleed): a rebuilt row at a colliding key after the clear must fall
back to the content estimate, not the stale remembered height; making the clear a
no-op fails the test.
Round-2 gate cert (iOS CORE): the touch predicate _isTouchLikeMessageViewport
used matchMedia('(pointer:coarse)'), which is true on BOTH Android and iOS. But
overflow-anchor is inert on iOS WebKit (the repo's own static/style.css mobile
content-visibility block documents this — it deliberately does not set
overflow-anchor:none because it is a no-op on iOS and re-opens the #4856/#5338
jump on Android). So on iOS the stale-anchor refusal fired but its premise (let
the native overflow-anchor layer hold the viewport) is false → a scrolled-up iOS
reader was left unheld after above-viewport growth, the same class as the round-1
desktop regression, one platform over.
Split the platform check: add _isIOSWebKit() (classic iPhone/iPod/iPad UA, plus
iPadOS 13+ which masquerades as MacIntel but has maxTouchPoints>1 unlike a real
Mac) and exclude it from _isTouchLikeMessageViewport. The refusal now fires ONLY
on Android (pointer:coarse AND overflow-anchor actually works); desktop and iOS
both keep the explicit semantic realign / absolute snapshot.top restore.
Tests: 3 new predicate cases (iPhone, iPadOS-as-Mac, Android control), all
mutation-checked — removing the _isIOSWebKit exclusion fails exactly the iOS
cases while Android/desktop stay green; broadening _isIOSWebKit to any touch
device fails the Android control. The Node harness forces the navigator mock via
Object.defineProperty because Node 18+ ships a built-in read-only navigator that
a plain assignment silently ignores.
Note: iOS Safari cannot be exercised by the Node harness (the 'native anchor
holds' postcondition is mocked, never observed), so this needs a real iOS-Safari
recording before merge per the gate cert.
The node test harnesses (e.g. test_anchor_fallback_ownership) extract
renderMessages and _measureMessageVirtualRow by source and run them with every
collaborator stubbed by name. The new _applyUserRowIntrinsicHeight /
_rememberUserRowIntrinsicHeight helpers were not among those stubs, so the
extracted functions threw ReferenceError ("node behavior check failed") on the
CI shard that runs those files. Gate both cross-function call sites on
`typeof helper === 'function'` (same pattern already used across ui.js for
harness-extracted helpers). Production always has the helpers defined, so the
guard is a no-op there; a harness without them cleanly skips the reservation
and keeps prior behavior. No behavior change on the real page.