5 Commits
Author SHA1 Message Date
Thireus ☠ f698c8c9fd docs: write down when a live turn is dropped
AGENTS.md asks for the documentation to move with a runtime-behaviour change, and docs/CONTRACTS.md points at the stable-assistant-turn-anchors RFC for anything that reconciles a live turn with the settled transcript. That is what this change adds, so the rule belongs there.

A new part of the Settlement section says why a preserved live node is not evidence of a running turn, the four facts that have to hold before one is dropped, that ownership is proved from state rather than by comparing two rendered trees, and that only the branches which ADD a turn are gated. It also records the settled-scene half: a prose row that duplicates the final answer, exactly or as a prefix within ten percent of its length, is dropped where both settled renderers take their rows, and live rendering is untouched.
2026-09-21 19:29:01 +01:00
Thireus ☠ 2d48f4c306 ui: give the renderMessages probes the block helper
Four upstream tests read renderMessages out of ui.js and run a slice of it against a fake segment object. Since the message block goes in through _insertSegmentBlock, three of them stopped at "ReferenceError: _insertSegmentBlock is not defined" and the fourth still pinned the old seg.insertAdjacentHTML line, which took twelve of the fifteen test shards red on this branch and nowhere else.

test_status_command_card.py defines the helper in its own harness, one line that does what the real helper does without a browser. test_compaction_current_summary_fallback.py and test_anchor_fallback_ownership.py evaluate the real helper from ui.js alongside the functions they already evaluate; their DOM shims return no template content, so it takes its insertAdjacentHTML fallback exactly as before. test_sprint49.py pins the new call, and its point is unchanged: the footer still rides in the same block as the body.

A new case in tests/test_issue2051_duplicate_assistant_answer.py pins why the helper exists, which answers a review note: the block is parsed into a template and the nodes are MOVED, so a repeated call adds nothing, and insertAdjacentHTML survives only as the fallback. All three insertions go through it.

tests/test_anchor_fallback_ownership.py, tests/test_compaction_current_summary_fallback.py, tests/test_sprint49.py, tests/test_status_command_card.py: 34 passed. The three #2051 files: 12 passed.
2026-09-21 19:28:49 +01: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