Commit Graph
935 Commits
Author SHA1 Message Date
nesquena-hermes a6d6b3da85 Merge PR #7374 from Thireus: fix duplicate assistant answer render (#2051) 2026-09-23 02:28:29 +00:00
nesquena-hermes 0378ed4e34 fix(routes): preserve live cards on cross-profile 409 collisions
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 *_).
2026-09-22 10:21:22 +00:00
Thireus ☠ 9d0c629b55 fix(ui): stop duplicating the final answer and a picker row (#2051)
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.
2026-09-21 10:38:40 +01:00
Thireus ☠ 166ca0ec05 fix(ui): build message blocks as nodes, not from an HTML string (#2051)
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.
2026-09-21 10:38:40 +01:00
Thireus ☠ 8d3c405d68 fix(ui): stop rendering a settled assistant answer twice (#2051)
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
2026-09-21 10:38:39 +01:00
webtecnica da4e4ef316 fix(picker): catalog is authority for @custom labels; keep plain-lane colon models whole
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.
2026-09-20 06:33:58 +00:00
webtecnica e71c3e1f97 fix(picker): stop truncating model names with colons 2026-09-20 06:33:58 +00:00
Anthony Ruiz 8fe3325e12 fix(ui): keep current summary visible next to stale compaction markers
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.
2026-09-20 03:48:19 +00:00
Anthony Ruiz 44b2a57109 fix(ui): preserve all pre-window compaction cards 2026-09-20 03:48:19 +00:00
Anthony Ruiz 3eb06c192c fix(ui): preserve compaction cards across virtual windows 2026-09-20 03:48:18 +00:00
Anthony Ruiz ac83c94637 fix(ui): keep context compactions visible in long transcripts 2026-09-20 03:48:18 +00:00
n eb7f5df284 Merge PR #7394: fix(tts): honor the persisted TTS engine when requests omit it 2026-09-20 00:02:05 +00:00
nandJayaMegelar 49d4aab4e2 fix(renderer): match the table-row guard to the real table grammar
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>
2026-09-18 22:33:38 +00:00
nandJayaMegelar 75d51d5ee2 fix(renderer): repair two gate-found defects in the #7618 fix
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>
2026-09-18 22:24:45 +00:00
Jaya Megelar Cakrawarty 38ab9f51fa fix(renderer): preserve <br> in table cells and fix bullet list italic collision 2026-09-17 11:59:46 +08:00
Matt Van HornandClaude Fable 5.1 cf62bb87bf fix: send engine=edge from the first-party Edge callers
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
2026-09-16 12:24:30 -07:00
Kevin Meinon 906aebefa9 fix(scroll): do not unpin a pinned tail reader on an above-tail collapse clamp (#7605) 2026-09-16 07:03:36 -07:00
842b2ac471 release: composer footer-fit jitter fix (#7275) (#7483)
* 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>
2026-09-09 18:11:21 +00:00
n 049a1ef3de stage: merge #7082 (stream word-tearing) 2026-09-08 21:16:39 +00:00
n 649083eb7f stage: merge #7277 (submitEdit re-entry guard) 2026-09-08 17:22:39 +00:00
n 990a92a18e stage: #7335 jsArg inline-handler escaping (@zicochaos) 2026-09-08 16:05:36 +00:00
webtecnica 2c0b4902d0 fix(renderer): emit nested lists iteratively to avoid stack overflow (#6700) 2026-09-08 01:16:01 -03:00
webtecnica b810e5466f fix(renderer): preserve ordered flag on list items (#6700)
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.
2026-09-08 01:16:00 -03:00
webtecnica 13dd97d60d fix(renderer): preserve nested mixed ul/ol list hierarchy (#6700) 2026-09-08 01:16:00 -03:00
n a8d10887df fix(picker): don't let a separator-only search match every model
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.
2026-09-08 00:58:05 +00:00
n 150c766875 fix(transcript): mount the render window when jumping to session start mid-stream
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.
2026-09-08 00:26:54 +00:00
n 94b70ce8b8 Merge PR #7385 2026-09-08 00:16:17 +00:00
n d52596a269 Merge PR #7427 2026-09-08 00:16:17 +00:00
LeonardoLGDS e60323d5cd Fix #6799: perf(transcript): virtualization ON + long session = endless scroll to re-render loop (~5-6 re-renders/sec idle), CPU 100%+, text selection broken 2026-09-04 15:56:47 +00:00
webtecnica ec4bac9fc9 fix: model picker search matches OpenRouter display names (#7228) 2026-08-31 15:29:28 -03:00
Anthony Ruiz 69e4fa3b98 fix(stream): adopt parser-owned fade row instead of recloning it
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.
2026-08-27 23:58:14 +00:00
Anthony Ruiz 9388c6495b fix(stream): keep parser DOM as fade-row authority after rebuild
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.
2026-08-27 23:58:14 +00:00
Anthony Ruiz 259631e46b fix(stream): resume cursor excludes parser pending tail on cursorless markdown rebuild
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.
2026-08-27 23:58:14 +00:00
Anthony Ruiz 34f10d2545 fix(stream): mute rendered prefix and keep parsed markdown on fade-row rebuild
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.
2026-08-27 23:58:14 +00:00
Anthony Ruiz 59d2f295c8 fix(stream): stop tearing live progress words across block boundaries
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.
2026-08-27 23:58:14 +00:00
Sebastian B d4e102fd38 fix(ui): encode inline on* handler args with shared jsArg() (#3797 class)
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.
2026-08-28 00:45:45 +01:00
Claude 5c21e2db90 Merge remote-tracking branch 'origin/master' into claude/webui-edit-double-submit 2026-08-24 19:31:10 +00:00
Claude fc528113f8 fix(webui): guard submitEdit against re-entry (destructive double-submit)
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
2026-08-24 16:07:22 +00:00
bsgdigital 15523b382d fix: preserve last char before tool call in streaming text
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.
2026-08-21 18:29:24 +00:00
webtecnicaandnesquena-hermes 65c185478c fix(ui): suppress false loopback-only warning when public URL is configured (#6573)
* 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>
2026-08-20 21:32:08 -07:00
4d03578192 fix(#6611): make regeneration start atomic (#6677)
* fix(#6611): make regeneration transaction canonical

* fix(#6611): close cross-provider regeneration blockers

* fix(#6611): harden regeneration transaction boundaries

* fix(#6611): bind canonical context and fork lineage

* fix(#6611): restore active turn token stamping

* test(#6611): make regeneration regressions CI portable

* test(#6611): preserve the tracked regeneration error fixture

* fix(#6611): preserve extracted session harness contracts

* fix(regeneration): preserve concurrent and imported turn ownership (#6611)

* fix(regeneration): honor imported turn ownership safely (#6611)

* test(regeneration): cover both session lock outcomes (#6611)

* test(regeneration): clear unused transaction fixtures (#6611)

* fix(#6611): consume public active-turn marker

* ci: re-trigger (Playwright install hung ~2.5h on GH runners, warm-cache retry)

---------

Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
Co-authored-by: n <a@n>
2026-08-19 11:11:53 +00:00
83e4903274 Batch release — double-render fix (#7109) + inline-video preload (#7076) (#7113)
* 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>
2026-08-18 00:11:28 +00:00
747b5a6913 Batch release exp-v0.52.236 — dotted model labels (#6628) + MCP SDK pin (#6616) (#7103)
* 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>
2026-08-17 06:37:52 +00:00
e88aff4388 Batch release exp-v0.52.234 — O_BINARY (#7077), native-control font (#6871), mobile nav order (#6909) (#7099)
* 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>
2026-08-17 01:42:02 +00:00
37c59baf68 fix(ui): prevent OOM on tab focus for long sessions (#7006)
* 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>
2026-08-14 19:42:15 -07:00
starship-sandnesquena-hermes 108841ba31 fix(composer): dismiss reconnected status (#7028)
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>
2026-08-14 18:27:00 -07:00
3a95d82531 fix(chat): prevent first response jump from snapping back (#6621)
* 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>
2026-08-13 21:17:42 -07:00
f556cf349e Release: chat stream scroll-bounce fix (#6257) (#6993)
* 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>
2026-08-13 17:40:54 +00:00
83b5ccb0fe Release: CJK full-width punctuation autolink fix (#6792) (#6991)
* 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>
2026-08-13 17:08:40 +00:00
happy5318 8841662768 fix: close snapshot capture/serve exfiltration holes + prefix-URL corruption (review round 2)
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).
2026-08-13 18:02:03 +08:00