Gate follow-up for #7710. Three front-end handlers treated EVERY 409 as a
domain-specific stale/expired signal, so the new session_profile_mismatch
response would have:
- messages.js: hidden a LIVE clarify card and mislabelled it expired
- ui.js: retired a LIVE compression-recovery card with a false note
- commands.js: rendered a false 'Compression failed' error state
Each now routes through _sessionProfileMismatchFromError() first and leaves
the card standing. Also captures the status in the import_cli test mock so a
wrong status can no longer pass (the mock swallowed it into *_).
The interrupt notice, and with it any turn-final assistant message, could be rendered twice through the compact Worklog fold (#6948). _assistantMessageBelongsInWorklog() folded on the _live marker before it reached the rule that protects a turn's final answer, but the caller only consults that predicate once the turn has settled, so a marker seen there is a leftover of the live-snapshot projection rather than an ongoing stream: the answer was hidden inline, echoed into the Worklog as a .wl-reason row, and the turn was left with no visible content, which the #3875 blank-turn fail-safe then revealed as two copies. A turn-final message with visible text is now kept out of the fold; every other _live message folds exactly as before.
Two nearby paths could produce the same duplicate on their own, so both are tightened: the settled Worklog echoes an anchor's prose only when that anchor was actually folded into it (assistant-segment-worklog-source), which is the proof the group construction already used, and the blank-turn fail-safe materializes a deferred Worklog (#5839) before judging it empty, so it no longer un-hides the folded segments and then renders the deferred rows beside them.
The composer picker listed one configured model twice because the provider-qualified spelling of its badge key was not recognised as an alias. _normalizeConfiguredModelKey() strips only one leading slash segment (#3360 keeps vendor_a/x and vendor_b/y/x distinct), so vendor/model and custom/vendor/model normalise to different keys and renderModelDropdown() synthesised a second row. _isEquivalentConfiguredModelEntry() now accepts <provider>/<model> under the same proof the @<provider>: rule uses - the badge declares the provider, the key starts with that provider's prefix, and an existing row from that provider normalises equal to the remainder - so only a duplicate of a row the catalog already produced can be dropped.
Adds tests/test_issue2051_duplicate_interrupt_notice.py and tests/test_issue2051_duplicate_model_picker_entry.py.
A settled assistant answer could render TWICE inside a single `.assistant-segment` -- `msg-body, msg-foot, msg-body, msg-foot`, the two copies byte-identical -- while the session data, the server responses and the emitted markup were all correct. It survived reloads, was unreproducible on a clean machine, and could not be explained by any render path.
The cause is outside the app. Privacy / anti-fingerprinting extensions wrap the DOM insertion APIs so they can re-scan the document after every mutation. The reporter identified CanvasBlocker 1.12 in their own profile; its `lib/iframeProtection.js` -> `protectDOMModifications()` wraps `appendChild`, `insertBefore` and `replaceChild` on Node.prototype, `append`, `prepend`, `insertAdjacentElement`, `insertAdjacentHTML`, `insertAdjacentText` and `replaceWith` on Element.prototype, and the `innerHTML` and `outerHTML` setters, and installs them with `exportFunction`, so they still stringify as `[native code]` and cannot be told apart from the real thing by the page.
A wrapper that ends up executing the wrapped call twice is harmless for node-based insertion but not for string-based insertion. Measured on the reporter's exact 2342-char emit string, in Chromium 152 and Firefox 154: a doubled `insertAdjacentHTML` rendered 2 bodies before this change and 1 after, while a doubled `appendChild`, `insertBefore`, `append` or `innerHTML` setter rendered 1 body both before and after.
Only the HTML-string path multiplies: `insertAdjacentHTML` parses and appends a fresh copy every time it runs, whereas appending a node MOVES it (a second call is a no-op) and an `innerHTML` setter REPLACES (a second call is idempotent). That is why exactly this one construct, of all the wrapped APIs, produced the duplicate -- and why switching to a different insertion API is a real fix rather than a workaround for one extension.
Parse into a `<template>` and move the resulting nodes in. The DOM is byte identical -- verified by comparing `innerHTML` of both constructions on a full session render in both engines -- but every step is now idempotent, so the renderer no longer depends on the insertion API being executed exactly once. Three call sites, one helper, with a string-path fallback for any environment without `<template>`.
The live-turn path (`ensureAssistantRow`) is deliberately left alone: it builds with `document.createElement` + `appendChild`, which the measurements above show is already immune.
Reproduction, with no extension needed: `const o = Element.prototype.insertAdjacentHTML; Element.prototype.insertAdjacentHTML = function (p, h) { o.call(this,p,h); o.call(this,p,h); };` then open any session -- before this commit every assistant message renders twice.
Two independent paths could put a second copy of an already-settled assistant answer on screen; the stored data always held exactly one row.
1. Dead live-turn node. The assistant row is persisted a few ms before the stream's terminal event clears S.activeStreamId, so the #6948 preserve guard still treats a finished #liveAssistantTurn node as live. Both live-turn insertion paths (renderMessages()'s re-attach and restoreLiveTurnHtmlForSession()) have one branch that APPENDS a turn the settled rebuild did not produce; the leftover node keeps id=liveAssistantTurn, is re-preserved on every later render, and the duplicate survives until a reload.
2. Settled activity scene. A process_prose row whose text IS the final answer is suppressed by the transparent-stream renderer but rebuilt as a second .assistant-segment by the compact-worklog row builder. The existing near-equality matcher never fires for short answers because of its absolute shorter>=80 floor (the reported reproduction is a 35-char greeting).
Dropping a live node is now an OWNERSHIP proof, never a text comparison: the transcript must end with a settled assistant message carrying this stream's id (_anchor_stream_id / scene identity), and the node must contain nothing the settled rebuild could not have produced (no unpersisted tool card, reasoning row, or second live segment). Unknown node shapes fail closed and keep the turn, so a genuinely live turn - including one whose text matches an earlier answer - is never dropped. The settled-scene half compares raw markdown at the single chokepoint both settled renderers share.
Adds tests/test_issue2051_duplicate_assistant_answer.py: four tests that fail on the pristine tree and pass with this change, including a wiring test that fails loudly if either append branch loses its guard.
Fixes#2051
The #7240 fix peeled the leading @custom: segment on first-colon split, but a
plain-lane custom id whose model carries a tag/variant colon
(@custom:ollamacloud/qwen3.5:397b) was still misread as slug=ollamacloud/qwen3.5,
rendering only "397b". Two changes:
- _dynamicModelLabels (the backend-populated catalog) is now consulted BEFORE
the string peel: the backend knows the true provider/model split and ships an
exact m.label per routing id, so catalogued ids — including plain-lane colon
models and collision cases — render verbatim.
- The legacy string fallback now treats a slash-bearing first segment as the
model itself (a provider slug is a config key or host:port authority and
never contains '/'), mirroring the '/' means routable rule api/config.py
applies when building ids.
Tests: add plain-lane @custom:ollamacloud/qwen3.5:397b controls, a
post-catalog dynamic-label control, and a collision case proving labels stay
distinct by full routing id. Driver now accepts an optional pre-hydrated
catalog map.
The settled current-summary card was only created when no compaction
marker was loaded at all. A transcript with older (stale) markers plus a
newer `compression_anchor_summary` that no loaded marker references
therefore lost the current summary, which master still rendered.
Decide the fallback from `_latestCompressionReferenceMessage()`
(`referenceMessageRawIdx < 0`) instead of marker presence, and feed that
decision into `_selectCompactionCardPlacements()` before any card node is
built so the fallback is the single preserved-task owner and marker cards
never attach the task list a second time.
Add a render-level regression that executes the real `renderMessages()`
over 220 messages, three stale markers, one preserved task-list message
and a newer summary: the current summary is visible and exactly one task
owner is rendered, in full, virtualized and server-truncated windows.
Third gate finding: the table-row predicate accepted ANY pipe-wrapped line, but
the downstream table parser only accepts a run of pipe-lines whose SECOND line
is a separator. So pipe-delimited prose such as '| note<br># heading |' was
treated as a table row and kept its literal <br>, silently losing heading/list
rendering that master renders correctly.
The guard now classifies complete blocks with the parser's own grammar
(row regex + separator check) and converts <br> on every unclassified line.
Verified: all non-table cases now match master byte-for-byte, while genuine
multi-row tables keep <br> in every data cell.
Co-authored-by: JayaMegelar <JayaMegelar@users.noreply.github.com>
The release gate (Codex, adversarial pass) reproduced two SILENT regressions
introduced by the original implementation. Both are fixed here with
revert-sensitive regressions.
1. Collidable sentinel. Table-row <br> was stashed as a fixed \x00BR\x00 token
and that token was unconditionally rewritten back to <br> afterwards, so
user-supplied text containing the literal token was corrupted -- in prose it
became a <br>, and inside a link URL it broke the anchor apart. Replaced with
conditional conversion: <br> becomes a newline only on non-table-row lines,
so no placeholder is ever introduced and nothing user-suppliable is rewritten.
2. Lost HTML emphasis. The stricter italic regex (which correctly stops
'a * b * c' from italicising) also rejected the '* x *' produced by the
<em>/<i> pre-pass, so '<em> italic </em>' degraded to literal asterisks -- and
to a bullet list at line start. Boundary whitespace is now kept OUTSIDE the
generated delimiters.
Verified against the real renderMd(): output for both cases now matches master,
while the table-cell <br> and spaced-asterisk fixes still hold. A revert probe
confirms all five new regressions fail against the defective implementation.
Co-authored-by: JayaMegelar <JayaMegelar@users.noreply.github.com>
The settings UI writes localStorage immediately but persists the server
engine through a debounced save, so a first-party Edge selection could
fall through the new server default to a stale ElevenLabs/OpenAI
engine. Both WebUI Edge callers now pass engine explicitly; regressions
cover both call sites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNmJZVV9u6pBhcwVhwkPp6
* fix(composer): stop footer fit from jittering the transcript during SSE
_fitComposerFooter() measured overflow by REMOVING the .cf-icons/.cf-burger
stage classes, reading scrollWidth, then adding them back. Between those two
steps the footer is laid out at full width: the composer grows a few px and
#messages loses the same amount of clientHeight, then gets it back on the
next frame.
For a pinned reader that is a visible up/down jitter of the whole transcript,
because the fit pass runs on every context-indicator update — which fires
continuously while an SSE turn is streaming.
Measured with a real Chromium probe instrumenting the scrollTop setter,
scroll events, ResizeObserver and MutationObserver:
before: 4 non-JS backward jumps of exactly -8px per streamed turn,
each one ~30ms after a `ctx-indicator-wrap` -> `composer-footer`
class mutation pair
after: 0 backward jumps over 5 streamed turns on 2 different sessions
Fix: freeze the footer's layout box (explicit height + visibility:hidden)
for the duration of the measurement, so the intermediate expanded geometry
is never committed to the screen, and restore the resolved stage in the same
task. The adaptive behaviour is unchanged — verified across 8 viewport
widths (1600 -> 420px), with the correct full/icons/burger stage at each and
no residual inline style left behind.
Note: the guard added in the tail-jitter work is what keeps the reader pinned
here; before it, the same footer jitter unpinned the reader instead, which
masked the oscillation behind a worse bug.
* test(composer): cover footer fit freeze across full/icons/burger stages
Add a behavioural node driver test for _fitComposerFooter() that runs the
actual function from static/ui.js against a small layout model where the
stage classes dictate the footer's natural height and the left cluster's
content width. Every class mutation, inline-style write and overflow
measurement commits a layout sample, so the recorded footer heights and
messages clientHeights are what a browser would have painted.
The matrix starts from each stage (full, icons, burger), forces each
overflow outcome, and runs with empty and caller-owned prior inline
styles. It asserts the resolved stage classes, that the footer border-box
height and messages clientHeight stay pinned while the ladder is probed
(and do not move at all in the steady state), that no intermediate stage
is ever painted, and that prior inline height/visibility are restored
verbatim. It also covers the zero-height skip and an exception during
measurement, and includes a control run with the freeze made ineffective
to prove the harness reports the original jitter. Against the pre-fix
ui.js the height, jitter, painted-stage and style-restoration assertions
fail.
* docs(changelog): stamp #7275 composer footer-fit jitter fix
* ci: re-trigger (playwright browser install hung on GH runners)
* ci: re-trigger (google chrome apt mirror hash-sum mismatch, external infra)
---------
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
Co-authored-by: n <a@n>
openItem() received ordered but dropped it from the item constructor, so
it.ordered was always undefined and every item looked unordered at the
task-list check. An ordered item whose text begins with [x] or [ ] (e.g.
'1. [x] shipped') was silently converted to a task icon and lost its
literal prefix. Task-list rendering now applies only to unordered items.
Codex gate finding on #7385: the new fold-matching (#7228) normalizes the search
term by stripping [\s._-], so a separator-only query ('.', '-', '_') folds to '' and
String.includes('') is always true — every model matched. Verified: searching '.'
returned all models, including ids with no dot.
Guard the folded clauses with a non-empty foldTerm, keeping the literal
name/id includes() checks so '-' still matches ids that really contain one.
Real searches ('ox alpha' / 'ox-alpha' / 'sonnet') are unchanged.
Codex gate finding on #7427: moving _scheduleMessageVirtualizedRender() below the
_freshProgrammaticScrollActive() guard (the idle re-render-loop fix) removed the ONLY
thing that mounted the new render window on the jumpToSessionStart() streaming path,
which deliberately skips renderMessages(). Reproduced in Chromium: 160 rows produced a
14,827px spacer and zero visible rows (blank transcript) where master mounted correctly.
Schedule the virtualized render explicitly in the streaming branch, after invalidating
_messageVirtualWindowKey, preserving #7427's listener guard and its idle-loop fix.
After rewind/cache-miss the keyed visible row stayed distinct from the
persistent parser candidate, so every later growth frame deep-cloned the
parser DOM into the old row. That cut off the previous tail word's
~620ms fade and broke word-node identity.
Promote the parser-owned candidate into the existing row's DOM position
once, mute the already-rendered prefix on the real parser body, and
return the adopted node so later keyed renders hit existing === node.
Regression: two post-transition growth frames keep the first new tail
span as the same .is-new node until an explicit animationend.
Cursorless markdown rebuilds cloned the live parser tree, then later
source growth appended raw bytes through _appendTransparentFadeText.
Incomplete emphasis (Hello ** / Hello **world**) became literal
**world** while the candidate already had <strong>world</strong>.
Parser-owned candidates now keep reconciling from the parsed DOM.
Pending and MEDIA tails stay on the parser. Rendered-prefix mute is
unchanged.
Greptile P1 follow-up on the fade-row rebuild: when a cursorless live
markdown row adopts the incremental node's cloned DOM, that DOM lags the
source text by the streaming parser's held-back tail (pending/text
buffers). Keeping the full source-length resume cursor made the next
reconciliation append nothing, so the pending characters stayed missing
from the live row until settlement.
Read the held-back length from the parser bound on the candidate body
(_smdBindParserIdentity) and trim the source-space cursor by exactly
that length, so the cursor matches the adopted content and the next
reconciliation re-appends the pending tail.
Regression: rebuild while the parser holds an unrendered tail, then
reconcile again — the pending text must appear in the live row before
settlement.
Review follow-ups for #7082:
- Maintainer should-fix: the no-cursor rebuild branch of
_refreshTransparentFadeProseRow cleared the body and re-wrapped every
word as .is-new, so the whole visible row dipped to opacity 0 and
faded back (~620ms), and wholesale node replacement invited the
one-time scroll-anchor bounce (#6257). Now the rendered text is
snapshotted before the clear and the messages.js
_streamFadeMuteRenderedPrefix idiom is re-applied after the rebuild,
so only genuinely-new tail words animate. The helper is exported on
window (__streamFadeMuteRenderedPrefix) the same way
__anchorProseIncrementalNode is.
- Greptile P1: the same branch passed raw source text to
_appendTransparentFadeText, flattening live markdown rows to literal
syntax (links/emphasis/headings lost until settlement). The rebuild
now adopts a deep clone of the candidate's parsed .msg-body (the
incremental streaming-markdown output) when one exists, so the parsed
DOM survives; the plain-text rebuild remains only as fallback for
candidates without a parsed body. The resume cursor stays in source
space when rendered text diverges from source (markdown), and shrinks
to the rendered prefix for plain prose so the parser's held-back
character is re-appended on the next delta instead of dropped.
Regression tests: rebuild must not re-animate the already-rendered
prefix (tail-only .is-new), and rebuild must keep parsed <a>/<strong>
DOM with no literal markdown in the visible text. The two existing
tests of this PR are unchanged and still pass.
Transparent-stream progress rows are built incrementally by
`_anchorProseIncrementalNode`, which streams source text into the vendored
streaming-markdown parser. That parser always holds the most recently written
character in its pending buffer, so a live row's rendered `.msg-body` text
trails its source text by one character.
`_refreshTransparentFadeProseRow` resumed appending from the
`data-stream-fade-text` cursor but fell back to `body.textContent` when the
attribute was absent — exactly the case for incrementally built rows, which
never set it. That fallback mixed two coordinate spaces (rendered text vs
source text), so the computed delta started one character early, in the middle
of a word. The delta was then appended as a sibling of the parser's open `<p>`,
and since `<p>` is a block box the tail rendered on its own line: a single word
split across two lines ("Ces deu" / "x fichiers").
Two independent fixes:
1. Resume strictly from the source-space cursor. When no cursor exists we
cannot know how much source is already rendered, so rebuild the body
instead of guessing a delta from rendered text.
2. Append into the trailing block element rather than the `.msg-body` root, so
a continuation of the current sentence stays on the same line even if a
cursor is ever stale.
Regression tests drive the real vendored parser to build a live row exactly as
production does, assert the parser really does lag its source, and fail if any
word is torn across a block boundary. The existing reconcile harness gains the
new helper dependency.
esc() is HTML-only escaping; the browser decodes attribute entities BEFORE
parsing an inline handler, so onclick="fn('${esc(id)}')" let any id
containing a quote break out of the JS string literal. _kanbanJsArg solved
this for kanban dependencies only; promote it to jsArg() in ui.js and route
every inline-handler argument through it (panels.js cron/kanban/notes/
passkeys/gateway/checkpoints, sessions.js handoff hint, onboarding.js OAuth
device code). Adds a Playwright round-trip proof and a source guard against
reintroducing the esc()-only pattern.
Clicking "Send edit" a second time while the first is still working starts a
second full truncate. Observed in the wild on a laggy instance: seven concurrent
POST /api/session/truncate, 5.2s / 8.3s / 6.7s / 7.4s / 8.1s / 8.5s, from one
user clicking repeatedly because the UI had not yet acknowledged the first click.
submitEdit's only guard was S.busy — but send() does not set that until the LAST
line of the function, after two multi-second awaits:
if(!S.session || S.busy) return; // S.busy still false
const absoluteKeepCount = _oldestIdx + msgIdx;
await _ensureAllMessagesLoaded(); // ~6.4s on a long session
await api('/api/session/truncate', ...); // 5-8s under load
await send(); // S.busy finally set
On a 2000-message session that is a 10s+ window in which every further click
runs the whole destructive path again.
The load is the visible symptom; the hazard is worse. absoluteKeepCount is
captured BEFORE the awaits deliberately — at click time _oldestIdx is the loaded
window's offset and msgIdx is window-relative, so the sum is the true absolute
index (pinned by test_submit_edit_captures_absolute_before_await, the #2184
pattern). But the first call's _ensureAllMessagesLoaded() sets _oldestIdx = 0.
A second call entering after that computes `0 + msgIdx` from a msgIdx that is
still window-relative — a far smaller keep_count. Truncating a long session to
that would delete most of its history.
Fix: a module-scope _submitEditInFlight flag, raised before the first await and
cleared in a finally. Guarded at the function rather than at the click handler
because the edit is also submittable with Enter (the keydown handler clicks the
button), so a handler-only guard would leave that path open, and because a future
caller would silently reopen the hole. The finally means an early return or a
throw cannot wedge editing off for the rest of the page's life.
Deliberately NOT changed:
- The pre-await capture stays where it is. Recomputing keep_count after
_ensureAllMessagesLoaded() looks like the obvious fix and is wrong: it would
pair _oldestIdx=0 with a window-relative msgIdx and break #2184 outright.
- regenerateResponse has the same S.busy-only shape, but no longer client-side
truncates (it delegates to the atomic startRegeneration) and validates the
clicked index against latestAssistantIndex, so a double click is refused
server-side rather than being destructive. Left alone to keep this diff to the
fault.
- No UI feedback added. Extra clicks are now harmless, but the button still does
not visibly acknowledge the first one, which is what provoked the clicking.
Worth a follow-up; it is a UX change, not this correctness fix.
Not reproduced end-to-end: this environment cannot run a real agent turn, so the
seven-truncate storm comes from the reporter's network log, not from a local
repro. The guard is verified by source-level test only.
test_submit_edit_is_guarded_against_reentry pins the flag, that it is raised
before the first await and cleared in a finally after send(). Confirmed to FAIL
when the guard is reverted. The three pre-existing tests in that file still pass.
Broad sweep: 2457 passed, 13 failed — all pre-existing test_update_banner_fixes
ordering pollution, identical on a clean tree. One earlier run of the same sweep
showed a 14th failure that did not reproduce across two further runs and whose
name I did not capture; flagging it rather than claiming a clean baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018y9pr7ppZgDuCZCT6x96kY
Replace .trim() with replace(/^\s+/, '') in _stripXmlToolCalls and
_stripXmlToolCallsDisplay to preserve trailing whitespace/characters
that appear before <function_calls> blocks in the streaming text.
Fixes: streaming text losing the last character before a tool call card.
* fix(ui): suppress false loopback-only warning when public URL is configured
When a user configures status.browser_url (e.g., via a reverse proxy
at https://dashboard.example.com), the Dashboard link correctly opens
that public URL, but _applyDashboardStatus() still displayed the
'loopback-only' warning because it only checked the browser's hostname.
Fix: suppress the warning when status.browser_url is present — the
user has explicitly configured a public URL, so the loopback bind
is intentional and the warning is a false positive.
Closes#6459
* fix(ui): base loopback warning on resolved dashboard target hostname (#6573)
* fix(ui): complete loopback classifier for dashboard warning (#6573)
---------
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* perf(media): preload visible chat videos
* fix(media): ignore detached visibility callbacks
* fix(ui): require provable live owner for live-turn preservation (#6948)
A completed assistant turn could render twice: when the stream ended
(S.activeStreamId cleared) but INFLIGHT[sid] was not yet cleaned, the
preserve guard treated bare rendered content (.msg-body presence) as
proof of liveness and re-attached the stale live DOM node OVER the
rebuilt settled transcript — a second copy of the same message. Data
was always clean (state.db, sidecar, /api/session each hold one row);
the duplicate existed only in the rendered DOM.
Preserve the live turn across the wipe only while there is a provable
live owner: an active stream (S.activeStreamId, the #3877 mid-stream
case) or explicit live-assistant evidence in the current message
projection (S.messages carrying a client-side _live / _activityBurstId /
_liveSegmentSeq marker — the reconnect/terminal-projection case). A
settled transcript has neither, so the durable settled render wins and
appears exactly once. The #5390 blank-turn guard (对话消失) is preserved:
a dead empty shell has no live projection either and is still dropped.
* changelog: exp batch — #7109 double-render fix + #7076 video preload
---------
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@gmail.com>
Co-authored-by: n <a@n>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(models): normalize dotted Bedrock/Vertex model IDs in labels
Split out of #6607 at reviewer request — it was unrelated to that PR's MEDIA
path handling and needed its own tests.
`us.anthropic.claude-opus-5` carries a cross-region routing prefix plus a vendor
namespace, and `mistral.mistral-large-2407-v1:0` adds a provisioned-revision
suffix. None of it belongs in a human label, so the turn footer rendered
"Us.anthropic.claude Opus 5".
Strip only the two shapes these hosts actually publish, against a CLOSED
provider allow-list:
<region>.<vendor>.<model> us.anthropic.claude-opus-5
<vendor>.<model> mistral.mistral-large-2407-v1:0
A generic "drop leading letters-only dot segments" loop was rejected because it
rewrites arbitrary uncatalogued IDs: `deepseek.v3` rendered as "V3" (vendor name
silently deleted) and `foo.bar.baz` as "BAZ". Dropping a vendor is additionally
gated on the remainder still naming the model, so `deepseek.v3` — where the
vendor IS the name — is left byte-intact.
Backend and frontend are paired: tests/test_dotted_model_label.py drives one
table through both `_get_label_for_model()` and `_stripDottedModelPrefix()` and
fails on divergence, including version dots (`gpt-4.1`, `qwen3.6-35b`),
URI-scheme IDs, and unknown vendors.
The Python half is inlined inside `_get_label_for_model` on purpose: the #3429
harnesses extract that function's source and eval it in isolation, so a
module-level helper NameErrors there. The #3429 JS driver is updated to pull in
the new helper for the same reason.
* fix(models): recognize `global` as a Bedrock routing prefix
Re-review catch, and a real gap: the catalog ships six
`global.anthropic.claude-*` IDs (api/config.py:1901-1909) and the first-party
routing notes use `global.anthropic.claude-…` as the canonical Bedrock shape,
but `global` was missing from the region allow-list. All six therefore kept the
noise this change exists to remove:
global.anthropic.claude-opus-4-7 -> "Global.anthropic.claude Opus 4 7"
Added to the region set in both implementations, which now read identically.
The root cause is two lists that must agree — the region allow-list and the
shipped catalog — so the new guard is catalog-driven rather than another
hardcoded region list: it scrapes every dotted `<head>.<vendor>.<model>` ID out
of api/config.py and asserts none of them keeps a routing/vendor namespace in
its label. A future routing prefix added to the catalog without updating the
region set fails there, instead of shipping mislabeled.
Also pins all six `global.*` IDs plus `us-gov` in the shared strip table (no
suite covered `us-gov` before either), and verified by mutation: dropping
`global` from the Python set fails 3 tests, dropping it from only the JS set
fails 2 — so backend/frontend parity drift is caught, not just a total absence.
Note: the review cited tests/test_provider_prefix_label_normalization.py and
tests/test_ui_model_label_parity.py as the suites to extend; neither exists in
this tree (nothing under tests/ referenced `us-gov` at all), so the cases live
in tests/test_dotted_model_label.py alongside the existing paired coverage.
* fix(models): add missing Bedrock vendors; make the catalog guard actually broad
Two self-review findings (hostile critic pass). Both are the same class as the
`global` gap the reviewer already caught, which means the guard I added for that
gap was too weak to prevent a recurrence.
1. Missing vendors. `luma`, `twelvelabs` and `ibm` are real Bedrock
foundation-model vendors and were absent from the allow-list, so genuine IDs
shipped with the namespace intact:
luma.ray-2 -> "Luma.ray 2"
us.twelvelabs.marengo-embed-2-7 -> "Us.twelvelabs.marengo Embed 2 7"
us.ibm.granite-3-8b -> "Us.ibm.granite 3 8B"
Added those plus `nvidia` and `snowflake` to both implementations.
2. The catalog guard inspected 6 of 75 dotted IDs. Its scrape regex only matched
three-segment `"id": "<region>.<vendor>.<model>"` literals with double quotes,
so the entire two-segment `<vendor>.<model>` shape -- the OTHER documented
shape this PR handles -- was invisible to it. A test that reads 8% of the
corpus while claiming to cover the catalog is worse than no test, because it
reads as proof.
Rewritten to scrape any quoted `id` value regardless of quote style or segment
count (75 dotted IDs now inspected), and to DERIVE the namespace heads from the
production `_regions`/`_vendors` literals instead of retyping them, so a set
that grows without a test update is still covered. Version dots (`qwen3.6-plus`,
`gpt-5.4`) are correctly skipped as non-namespaces.
Also added an explicit per-vendor round-trip test. It asserts no dotted NAMESPACE
survives rather than that the vendor word is absent, because a vendor legitimately
reappears inside some model names (`mistral.mistral-large-2407` -> "Mistral Large
2407") -- my first version of that assertion was wrong for exactly that reason.
Verified by mutation: removing the new vendors fails the round-trip test; deleting
the strip entirely fails 6 tests.
* test(models): drive real getModelLabel() in the parity oracle
The paired test compared _get_label_for_model() against itself, so JS
getModelLabel() never ran and nothing asserted the two sides agreed. It
stayed green when the JS dotted strip was reverted to a no-op AND when the
whole post-strip retry chain was deleted -- blind to both.
Replaced with two tests driven through the real getModelLabel() under Node,
reusing the driver already proven in test_issue3429_uri_scheme_model_label.
Only sinks are stubbed (_dynamicModelLabels empty as it is pre-fetch,
_fmtOllamaLabel identity); every decision function is the shipped source.
The oracle asserts the actual contract -- no routing/vendor namespace leaks
into either label -- rather than string equality of the two labels. Label
divergence is pre-existing and by design: at base dd7f6ac3, claude-opus-5
(no dot, untouched here) already labels 'claude-opus-5' in JS vs
'Claude Opus 5' in Python, and openai/gpt-4o 'GPT-4o' vs 'GPT 4O'.
getModelLabel() checks _dynamicModelLabels first (ui.js:6991), populated
from the server label (ui.js:3483,3494,3606), so the backend wins once a
catalog loads; the JS formatter is the pre-fetch fallback.
Dropping the JS post-normalization instead would regress the picker to raw
ids before catalog load (us.anthropic.claude-sonnet-4-5 -> 'claude-sonnet-4-5'
instead of 'Sonnet 4.5').
Mutation-checked: JS strip no-op fails 2, retry chain deleted fails 1,
'global' dropped from backend _regions fails 1. Region set derived from
api/config.py source rather than retyped.
No production code changed.
* fix(test): pin mcp SDK to compatible 1.x range with bootstrap guard (#6602)
Change to in requirements-dev.txt and the
mcp_server.py docstring, and add a bootstrap guard in mcp_server.py
that checks Server.list_tools existence at module load time.
The guard fails fast with a clear error message if an incompatible
mcp SDK (2.x) is installed, instead of producing dozens of
secondary errors at test collection/setup. This completes the
mitigation started in PR #6564 by adding a minimum version floor
and an import-time compatibility check.
* docs(changelog): note #6628 dotted model labels, #6616 mcp SDK pin
---------
Co-authored-by: Sam Painter <samfp@amazon.com>
Co-authored-by: webtecnica <webtecnica@gmail.com>
Co-authored-by: n <a@n>
* fix(navigation): preserve mobile action mirror order
Reposition stale extension action mirrors during synchronization without moving already-correct elements, preserving native tab order and keyboard focus.
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.6-luna
Assisted-by: Claude Code:claude-opus-5
* fix(typography): route native controls through UI font
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.6-luna
Assisted-by: Claude Code:claude-opus-5
* fix(windows): open anchored files in binary mode
* docs(changelog): note #7077 O_BINARY, #6871 native-control font, #6909 mobile nav order
---------
Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com>
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
Co-authored-by: n <a@n>
* fix(ui): prevent OOM on tab focus for long sessions
Coordinate visibility-recovery and session-updated refresh paths so they
never start concurrent full-transcript loads, window the O(N) pre-render
scans to the virtual render window, and bound the render-window expansion.
Closes#6999
* fix(ui): full turn context + collision-free cache hash (#7006 review rework)
Addresses the maintainer's CHANGES_REQUESTED on PR #7006:
- Turn-context maps (_assistantTurnFinalVisibleContentMap /
_assistantTurnVisibleContentMap) receive the FULL visWithIdx again.
The virtualized render window (head+tail around a gap) cut through
assistant runs and merged distinct turns across an omitted user
boundary, which could duplicate the final answer in Worklog/Thinking
or feed later-turn prose into the reasoning echo-strip. Turn context
is input, not output: it must always be complete.
- _addBoundedHash now streams the FULL string form through the FNV-1a
loop (no length+head+tail clip, no slice copies), and object fields
(attachments, tc.args, compression-anchor keys) are walked key-by-key
so no integral JSON.stringify() is allocated. Same-length middle-only
edits of tool args, attachment metadata, tool snippets, or
compression-anchor keys no longer collide in _sessionHtmlCache.
Regression tests:
- tests/test_issue6999_turn_context_virtual_gap.py: multi-assistant turn
with a virtualized gap proves full-context echo-strip suppresses the
exact final-answer echo, and documents the gapped head+tail failure.
- tests/test_issue2613_render_cache_signature.py: Node harness executing
the real _messageRenderCacheSignature() proves same-length middle-only
mutations change the signature for every structured field while the
old clip scheme collides on the same data.
Closes#6999
* fix(ui): coalesce session-updated under refresh guard; insertion-order cache hash
Re-gate round 2 for #6999:
- session-updated frames arriving while the external-refresh guard is held
are latched per SID (max announced count) and drained by the refresh
owner's finally with ONE guarded follow-up, instead of being dropped
(production does not guarantee a second event).
- _hashObjectInto walks keys in insertion order (matching Object.entries
render projection) with an explicit per-key index discriminator, so
opposite-insertion-order argument objects no longer share a cache
signature; recursion depth is now threaded (children increment, not
restart).
* test(jump-scroll): stub _loadingSessionId + _drainSessionUpdatedPendingCount in extraction harness
#7006 adds a reference to the pre-existing module var _loadingSessionId and a new
_drainSessionUpdatedPendingCount() helper inside refreshActiveSessionIfExternallyUpdated.
The jump-to-answer scroll-settle tests extract that function into a node harness, which
didn't declare them -> ReferenceError (19 parametrized failures). Add the missing stubs
to the harness preambles (no-op drain, since these tests exercise scroll ownership, not
the coalesce path -- real drain behavior is covered by test_issue6999_session_updated_coalesce.py).
Test-harness-only; production static/ untouched.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Keep reconnect confirmations transient so they stop consuming mobile composer space, while a single owned timer prevents stale notices from clearing newer status text.
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.6-luna
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(chat): keep response jump position after session load
Cancel pending load-time bottom settling before an explicit response jump takes scroll ownership. Add a regression test covering the first-click race.
* fix: gate response jump ownership by destination
* fix: keep response jumps programmatic through smooth scroll
Native smooth-scroll frames from response jumps could reach the manual scroll listener and claim reader ownership before the final destination was known.
- own response-jump scrolling with a generation- and session-scoped lifecycle
- reconcile sticky ownership from final 80px tail geometry
- preserve current low-delta wheel takeover semantics when integrating with master
- cover visible assistant, user-row, and virtualized paths through the production scroll listener
* fix(scroll): hold reader off-bottom during jump owner + cancel jump on explicit End (#6621 gate fixes)
Codex gate found two defects in the jump-scroll ownership mechanism:
- Jump during an active stream snapped back to bottom on the next token: the
owner suppressed the scroll listener but left the pre-jump pinned state, so
scrollIfPinned() reclaimed the bottom. Now _beginMessageJumpScroll unpins for
the ownership window and scrollIfPinned() no-ops while an owner exists.
- An explicit End click could be undone by the pending jump reconcile restoring
the stale unpinned snapshot. scrollToBottom() now cancels the active jump
owner first; _cancelMessageJumpScroll restores the preserved snapshot so a
non-reconcile cancel doesn't leak the transient unpinned state.
* fix(scroll): drop undefined currentSid global ref in _messageJumpSessionId (#6621 brick-class scope-undef gate)
The PR's _messageJumpSessionId() referenced a bare 'currentSid' global that
does not exist in ui.js (everywhere else it's a local const from
S.session.session_id). test_static_js_scope_undef flagged it brick-class
(#3696). The S.session.session_id fallback already IS the canonical accessor;
removed the dead first line and updated the harness to drive the session-change
case via S.session.session_id.
* fix(scroll): round-2 gate fixes for jump-owner state transitions (#6621)
Re-gate found three more state-transition edge cases from the temp-unpin window:
- wheel-up interrupting an active jump owner after the programmatic latch
expires now explicitly establishes the unpinned reader-owned state (was
cancelling the jump but leaving pinned -> next token snapped to bottom).
- _resetStreamScrollFollow() now cancels the jump owner FIRST, before its
pinned-state assignments, so a stream starting mid-jump can't have its pin
undone by the snapshot restore -> auto-follow stays enabled.
- _finishMessageJumpScroll() flushes a deferred external-session refresh after
reconciliation when the terminal state is pinned to the tail, so a refresh
deferred during the temp-unpin window isn't stranded.
* fix(scroll): make jump-owner guards typeof-safe + widen static test window (#6621)
The two _messageJumpScrollOwner guards (scrollIfPinned, scrollToBottom) are
pulled into other scroll test harnesses (test_issue6414, test_issue4856) that
stub the scroll env without declaring the new global; bare references threw
ReferenceError. Guard with typeof (matches the PR's own jumpScrollOwned check).
Also widen test_low_delta_wheel_intent_is_tracked_separately's source-slice
window 1400->1800 to still contain the (unchanged) _lastMessageWheelIntentMs
line after the +9-line wheel-up-during-jump block was inserted.
* test(scroll): add streaming-frame-hold + wheel-during-jump regression tests (#6621 Fable finding ii)
Fable UX gate flagged that the PR's tests model only the stale load-time settle
callback, not the streaming case. Add two node-harness tests:
- a streaming render frame (scrollIfPinned) fired inside the jump-owner window
must not snap the reader to the bottom (holds at target, 0 bottom-writes).
- a gentle wheel-up during the owner window after the programmatic latch stales
hands ownership to the reader UNPINNED at their position, never pinned
mid-transcript.
* fix(scroll): widen jump-owner takeover to downward + touch input (#6621 Fable S3)
Fable's re-review (on the fixed code) confirmed the streaming blocker + wheel-up
concern resolved, and probe-proved one remaining narrow regression: a gentle
wheel-DOWN or touch scroll during the owner window cancelled the owner and
restored the pinned snapshot but did NOT re-unpin (the takeover was gated on
wheelUp only), so the next streaming token yanked the reader to the bottom.
Widen the takeover to any message-pane scroll input during the owner window.
Add a wheel-down regression test; widen the #4970 static-slice window to 2000
to still contain the (unchanged) _lastMessageWheelIntentMs line.
---------
Co-authored-by: pxxD1998 <214340659+pxxD1998@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(stream): preserve fade nodes while prose is live
* fix(stream): preserve transparent fade nodes like assistant prose
The transparent-activity prose path (transparent_stream mode) kept the
pre-PR fade cleanup: _bindTransparentFadeCleanup() in static/ui.js still
replaced every animated stream-fade-word span with a fresh text node on
animationend, invalidating the native scroll anchor while the row is
still growing.
Mirror the messages.js fix so both live prose paths share one
node-preserving cleanup: remove only the transient is-new class and the
--stream-fade-ms inline property, keeping span identity until final
settlement rebuilds the persisted DOM.
Extend tests/test_smooth_text_fade.py with a sibling regression that
extracts the production _bindTransparentFadeCleanup() from ui.js and
fires animationend, asserting no replacement, is-new removal,
stream-fade-word retention, and timing-override cleanup (plus
source-string guards against replaceWith in the cleanup block). The new
test is red-before-green against the previous ui.js implementation.
* docs(changelog): stamp #6257 (stream fade scroll-bounce fix)
---------
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(renderer): exclude CJK full-width punctuation from autolink URLs
* fix(test): widen autolink block search window for CJK punctuation fix (#6792)
The CJK full-width punctuation fix expanded the autolink regex and
comment text, pushing target='_blank' and esc(clean) beyond the 600/
700-char search windows in tests. Widen to 1200 chars.
* fix(test): widen autolink window in test_issue470 to match 1200-char block
The CJK punctuation fix added comment lines that pushed href="${clean}"
beyond the 600-char search window in test_issue470.py::test_autolink_no_esc_on_href,
causing the same failure across 3.11/3.12/3.13 shard-4. test_issue342.py was
already widened in cc52202f; apply the same 1200-char window here.
* fix(test): drive real renderMd() for CJK full-width autolink behaviour
Replace the test-owned FIXED_REGEX copy in test_fullwidth_autolink.js with
a pytest + node harness that extracts and runs the ACTUAL renderMd() from
static/ui.js (same driver pattern as test_renderer_js_behaviour.py).
Coverage per review:
- every CJK trailing mark (),。;:!?、) through both the outer
paragraph pass and the inline pass (list items + blockquotes)
- ASCII trailing marks (regression guard)
- normal URLs with query params (href keeps raw &, text is escaped)
- Markdown links and pre-existing anchors are stashed, never re-wrapped
Reverse-verified: reverting the \uFF09 exclusion + CJK strip makes 27 of
38 assertions fail on the real production code.
* fix(test): drop stray f-prefix from placeholder-free asserts (F541)
* docs(changelog): stamp #6792 (CJK autolink punctuation)
---------
Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Addresses nesquena-hermes Round-2 review on #6979. All three MUST-FIX
items reproduced end-to-end are fixed; three SHOULD-FIX items folded in.
MUST-FIX 1 (data exfiltration) — capture/serve deny parity + source binding:
- The #3234 state/profile deny model is extracted into a single shared
predicate (routes._media_deny_reason) used by BOTH /api/media serving and
snapshot capture, so anything the serve path refuses is never captured in
the first place (STATE_DIR subdirs, webui_state subdirs, named-profile
roots, secret basenames, the snapshot store itself).
- Every captured digest now carries a server-owned canonical source-path
association (<digest>.src.json sidecar, tmp+rename atomic). /api/media
serves a digest only for the exact path it was captured from — replaying
a valid digest through a different allowed path falls back to the live
file instead of leaking stored bytes.
- Snapshot blobs are opened ANCHORED inside the store root (no-follow).
- Committed end-to-end regression: capture a denied state file and prove it
cannot be served through any path.
MUST-FIX 2 (data exfiltration) — configured store root denied regardless of name:
- get_snapshot_dir() is resolved and any bare path= target at or below it is
rejected BEFORE the allow/serve decision, so a custom
HERMES_WEBUI_MEDIA_SNAPSHOT_DIR (e.g. /tmp/custom-store-name) can no
longer be read via a plain path= request.
MUST-FIX 3 (silent corruption) — prefix-safe URL stamping:
- _stampMediaSnapshots now rewrites each COMPLETE path= query value
atomically (boundary-aware regex, decode whole value, exact map lookup)
instead of substring split/join. One path being a prefix of another
(/tmp/a.png vs /tmp/a.png.backup) can no longer corrupt the longer URL or
drop its digest. data-path placeholders use the same value-level rewrite
with HTML-entity unescaping. Prefix-path regression test committed.
SHOULD-FIX folded in:
- A recorded digest is FINAL: quota eviction no longer causes a later settle
to re-capture the current live bytes and rebind the historical message
(immutability restored + no quota-thrash I/O).
- is_valid_digest uses whole-string fullmatch (\Z) — a trailing newline can
no longer pass the gate.
- PDF/HTML fallback, download and open-full-page links now carry snap so
they open/download the frozen bytes.
Deferred (noted in review thread): emission-time capture (streaming.py) and
worklog scene-prose stamping — scene rows do not carry parent-message
snapshot metadata; threading it through the scene reconciler is a separate
data-pipeline change. Main transcript + transparent ordered segments remain
the old/new comparison surface.
Tests: +11 backend/frontend regressions (36 total in
test_media_message_snapshots.py), incl. the exact Round-2 repros.
Reverse-verified: the new tests fail on the round-1 head (8 failed).