From 52c962cf7d9342d523f77173dec63d80d34d3be5 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 17 Jul 2026 21:51:22 -0700 Subject: [PATCH] Release: Transparent Stream multi-segment prefix dedupe (#6189, @ai-ag2026) (#6217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(transparent-stream): drop stale final-answer prefix row in multi-segment settle (#5749 follow-up) A turn with interim assistant messages (prose interleaved with tool calls) could show the beginning of the final answer TWICE after watching it stream: once as a settled anchor-scene prose row (the live-token accumulator's last throttled snapshot) and once as the real assistant segment. The duplicate persisted until reload. Root cause: #5758 suppresses the accumulator row only when it sits "after the last tool row", but _completeSettledAnchorSceneForTurn appends the settled per-message tool rows AFTER the projected live rows — those re-list tools that ran EARLIER in the turn, pushing the boundary past the final segment's accumulator so the guard never fired. The stale prefix snapshot then survived into the persisted scene and rendered above the settled answer. Fix: judge final-segment eligibility against the LIVE projection's own chronology — a live-prose row belongs to the final segment iff no PROJECTED tool row follows it. Pre-tool narration that happens to prefix the final answer stays protected (existing #5758 regression tests still pass), and a new regression test pins the multi-segment shape. Verified end-to-end by replaying the captured run journal of an affected session through the real SSE live handlers in headless Chromium: duplicate before (answer prefix visible twice after settle), gone after; fresh-reload rendering unchanged. Rollback: revert this commit; behavior returns to pre-fix (duplicate prefix row after live-settle of multi-segment turns). Co-Authored-By: Claude Fable 5 * CHANGELOG: transparent-stream multi-segment prefix dedupe (#6189, @ai-ag2026) --------- Co-authored-by: ai-ag2026 Co-authored-by: Claude Fable 5 Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + static/messages.js | 18 ++- ..._5749_anchor_scene_client_prefix_dedupe.py | 10 +- ...ue5749_transparent_stream_prefix_dedupe.py | 137 ++++++++++++++++++ 4 files changed, 163 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ae2aa1..46c99b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixed +- **Transparent Stream no longer shows the start of the final answer twice after a multi-segment turn settles.** In a turn with interim assistant prose interleaved with tool calls, the beginning of the final answer could render twice after the live→settled transition — once as a stale live-token accumulator snapshot, once as the real assistant segment — persisting until reload. The earlier suppression gate (#5758) computed its "after the last tool row" boundary over the combined row list, where settled per-message tool rows re-list tools that ran earlier in the turn, pushing the boundary past the final segment's accumulator so the dedupe never fired. Final-segment eligibility is now judged against the live projection's own chronology (a live-prose row belongs to the final segment only when no *projected* tool row follows it), so the stale prefix is dropped while pre-tool narration and every tool/thinking row are preserved. Thanks @ai-ag2026. (#6189, #5749) + - **The frontend now reads the `msg_limit` ceiling from `/api/session` metadata instead of hand-mirroring the constant.** Following the ceiling clamp shipped in exp-v0.52.98, the backend advertises its `_MAX_MSG_LIMIT` as `_msg_limit_max` in every `/api/session` response, and the frontend reads it dynamically (`_msgLimitMax`, a module-scope value defaulting to the static fallback), so the two can no longer drift and the mirrored constant's drift-guard test is no longer needed. Older backends that omit the field fall back to the built-in default, so mixed-version deployments are unaffected. Thanks @webtecnica. (#6214, #6177) - **`GET /api/session?msg_limit=` is now bounded by a server-side ceiling, and the frontend paginates around it without dropping rows.** A client could request `msg_limit=1000000` (or an outline-jump path asked for `9999`) and force the server to assemble and serialize an unbounded message payload. The backend now clamps `?msg_limit=` to `[1, 500]` via a dedicated `_parse_msg_limit()` helper and sets the existing `_messages_truncated` signal when it clamps; the bare no-`msg_limit` path (branch/undo/jump-to-start) still returns the full transcript. The frontend mirrors the ceiling: `_loadOlderMessages` grows its tail window below the ceiling and switches to bounded `msg_before` backward paging once the server would clamp, and the outline jump uses the bare full-transcript path instead of the old `msg_limit=9999` hack. Two silent row-loss regressions found during review were fixed before ship: a raw-row-heavy `msg_before` page that textually repeated the current tail could be misclassified as the cumulative tail and wholesale-replace it (losing older rows), and a same-session refresh above the ceiling could shrink an already-loaded >500-row transcript to the last 500 — both now route through the correct paging/full-transcript path. Thanks @rh-id. (#6152, #6154, #6177) diff --git a/static/messages.js b/static/messages.js index cd763ec0..6f340e38 100644 --- a/static/messages.js +++ b/static/messages.js @@ -3552,14 +3552,28 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ for(const row of projectedRows){ if(row&&row.role==='terminal') orderedRows.push(row); } - const lastNonTerminalWorkRowIndex=orderedRows.reduce((last,row,idx)=>(row&&row.role==='tool')?idx:last,-1); + // #5758 gap: final-segment eligibility must be judged against the LIVE + // projection's own chronology. The settled per-message tool rows appended + // into orderedRows above re-list tools that ran EARLIER in the turn, so an + // index over the combined list pushes the "after the last tool row" + // boundary past the final segment's live-prose accumulator — its stale + // prefix snapshot then survives into the persisted scene and renders as a + // duplicate of the answer's beginning. A live-prose row belongs to the + // final segment iff no PROJECTED tool row follows it; pre-tool narration + // that happens to prefix the final answer stays protected. + const lastProjectedToolIndex=projectedRows.reduce((last,row,idx)=>(row&&row.role==='tool')?idx:last,-1); + const finalSegmentLiveProseRows=new WeakSet(); + projectedRows.forEach((row,idx)=>{ + if(idx>lastProjectedToolIndex&&row&&row.role==='prose'&&row.kind==='process_prose'&&String(row.source_event_type||'')==='token'&&String(row.local_id||'').startsWith('live-prose:')) finalSegmentLiveProseRows.add(row); + }); const rowIsLiveTokenFinalPrefix=(row,textKey,finalSegmentEligible)=>finalSegmentEligible&&row&&row.role==='prose'&&row.kind==='process_prose'&&String(row.source_event_type||'')==='token'&&String(row.local_id||'').startsWith('live-prose:')&&textKey&&finalKey&&textKey.length{ if(!row||typeof row!=='object') return; + const finalSegmentEligible=finalSegmentLiveProseRows.has(row); row=_anchorSceneSettleLiveRunningRow(row,hasSettledThinking); if(!row||typeof row!=='object') return; const textKey=_anchorSceneTextKey(row.text); - if(rowIsLiveTokenFinalPrefix(row,textKey,rowIndex>lastNonTerminalWorkRowIndex)) return; + if(rowIsLiveTokenFinalPrefix(row,textKey,finalSegmentEligible)) return; const isTextual=row.role==='prose'||row.role==='thinking'; if(isTextual&&_anchorSceneRowLooksLikeFinalAnswer(textKey,finalKey)) return; if(isTextual&&_anchorSceneRowTextOverlapsExisting(textKey,seenTextKeys)) return; diff --git a/tests/test_5749_anchor_scene_client_prefix_dedupe.py b/tests/test_5749_anchor_scene_client_prefix_dedupe.py index 31058e96..ee42e477 100644 --- a/tests/test_5749_anchor_scene_client_prefix_dedupe.py +++ b/tests/test_5749_anchor_scene_client_prefix_dedupe.py @@ -24,8 +24,14 @@ def test_settled_scene_keys_live_token_prefix_dedupe_to_final_answer_identity(): settle_body = _function_body(MESSAGES_JS, "_completeSettledAnchorSceneForTurn") final_overlap_body = _function_body(MESSAGES_JS, "_anchorSceneRowLooksLikeFinalAnswer") - assert "lastNonTerminalWorkRowIndex" in settle_body - assert "rowIsLiveTokenFinalPrefix(row,textKey,rowIndex>lastNonTerminalWorkRowIndex)" in settle_body + # The final-segment boundary must come from the LIVE projection's own + # chronology (projectedRows), not the combined orderedRows: the settled + # per-message tool rows appended there re-list tools that ran earlier in + # the turn and would push the boundary past the final segment's live-prose + # accumulator (the #5758 multi-segment gap). + assert "lastProjectedToolIndex=projectedRows.reduce" in settle_body + assert "finalSegmentLiveProseRows" in settle_body + assert "rowIsLiveTokenFinalPrefix(row,textKey,finalSegmentEligible)" in settle_body assert "rowHasNonLiveDuplicate" not in settle_body assert "_anchorSceneRowLooksLikeFinalAnswer(textKey,finalKey)" in settle_body assert "(shorter/longer)>=0.9" in final_overlap_body diff --git a/tests/test_issue5749_transparent_stream_prefix_dedupe.py b/tests/test_issue5749_transparent_stream_prefix_dedupe.py index b12c6c68..cb37358c 100644 --- a/tests/test_issue5749_transparent_stream_prefix_dedupe.py +++ b/tests/test_issue5749_transparent_stream_prefix_dedupe.py @@ -672,3 +672,140 @@ process.stdout.write(JSON.stringify(rendered)); {"label": "virtualized", "visible": True, "rowId": "session-prose:stream-1:4", "attachmentCount": 0}, {"label": "attachments", "visible": True, "rowId": "session-prose:stream-1:5", "attachmentCount": 1}, ] + + +@pytest.mark.reproduction +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_issue5749_multi_segment_settled_tool_rows_do_not_shield_final_accumulator(tmp_path): + """#5758 gap: in a multi-segment turn the settled per-message tool rows are + appended AFTER the projected live rows, so a combined-list "after the last + tool row" boundary never marked the final segment's live-prose accumulator + as final — its stale prefix snapshot survived settlement and rendered as a + duplicate of the answer's beginning above the settled segment. The boundary + must come from the live projection's own chronology: the accumulator (after + the last PROJECTED tool row) is dropped, while pre-tool narration and every + tool row survive.""" + final_answer = ( + "## Verdict\n\nThe report is mostly right but oversimplifies: curated skill " + "bundles help strongly across all tested harness combinations, while " + "one-shot self-generated bundles without quality control regress the " + "baseline on every configuration we measured. The correct takeaway is to " + "keep a curated, versioned library instead of disabling skills entirely." + ) + accumulator_prefix = final_answer[:70] + narration_text = "Checking the local skill inventory before judging the claim." + script = f""" +const src = {json.dumps(MESSAGES_JS)}; +function extractFunc(name) {{ + const start = src.indexOf('function ' + name); + if (start === -1) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for (let i = params; i < src.length; i++) {{ + if (src[i] === '(') depth++; + else if (src[i] === ')') {{ + depth--; + if (depth === 0) {{ close = i; break; }} + }} + }} + const brace = src.indexOf('{{', close); + depth = 0; + for (let i = brace; i < src.length; i++) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') {{ + depth--; + if (depth === 0) return src.slice(start, i + 1); + }} + }} + throw new Error(name + ' body did not close'); +}} +global.window = {{ + chatActivityMode() {{ return 'transparent_stream'; }}, + _chatActivityDisplayMode: 'transparent_stream', + _transparentStream: true, +}}; +global.S = {{ session: {{}} }}; +eval(extractFunc('_anchorSceneCleanText')); +eval(extractFunc('_anchorSceneTextKey')); +eval(extractFunc('_anchorSceneExistingRowKey')); +eval(extractFunc('_anchorSceneRowHasLiveIdentity')); +eval(extractFunc('_anchorSceneSettleLiveRunningRow')); +eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer')); +eval(extractFunc('_anchorSceneRowTextOverlapsExisting')); +eval(extractFunc('_anchorSceneMessageRowsHaveThinking')); +eval(extractFunc('_completeSettledAnchorSceneForTurn')); +function _anchorSceneActiveMode() {{ return 'transparent_stream'; }} +function _anchorSceneFinalAnswerText(message) {{ return message && (message.final_answer || message.content || ''); }} +// The settled per-message rows re-list the turn's tool as a completed copy; +// it lands AFTER the projected live rows in the combined ordering. +function _anchorSceneRowsByMessageIndex() {{ + return new Map([[2, [{{ + role: 'tool', + kind: 'tool_result', + source_event_type: 'tool_complete', + local_id: 'settled-tool-row-1', + tool_call_id: 'call-1', + text: '', + status: 'completed', + }}]]]); +}} +function _anchorSceneMessageRef(message) {{ return String(message && message.id || ''); }} +function _anchorSceneTurnDurationForSettlement() {{ return 0; }} +function _anchorSceneRowDisplayHintForMode(row, sceneMode) {{ + const hints = row && typeof row === 'object' && row.display_hints && typeof row.display_hints === 'object' ? row.display_hints : null; + if (sceneMode === 'transparent_stream') return (hints && hints.transparent_stream) || 'chronological_activity'; + if (sceneMode === 'compact_worklog') return (hints && hints.compact_worklog) || row && row.display_hint || 'activity_row'; + return row && row.display_hint || 'activity_row'; +}} +const messages = [ + {{ role: 'user', content: 'Prompt', id: 'user-1' }}, + {{ role: 'assistant', content: {json.dumps(narration_text)}, id: 'assistant-1' }}, + {{ role: 'assistant', content: {json.dumps(final_answer)}, id: 'assistant-2' }}, +]; +const scene = _completeSettledAnchorSceneForTurn(messages, 2, {{ + mode: 'transparent_stream', + final_answer: {json.dumps(final_answer)}, + lifecycle: {{ terminal_state: 'done' }}, + identity: {{ source_message_refs: ['legacy'] }}, + activity_rows: [ + {{ + role: 'prose', + kind: 'process_prose', + source_event_type: 'token', + local_id: 'live-prose:stream-multi:1', + text: {json.dumps(narration_text)}, + status: 'running', + }}, + {{ + role: 'tool', + kind: 'tool_started', + source_event_type: 'tool', + local_id: 'live-tool-row-1', + tool_call_id: 'call-1', + text: '', + status: 'running', + }}, + {{ + role: 'prose', + kind: 'process_prose', + source_event_type: 'token', + local_id: 'live-prose:stream-multi:2', + text: {json.dumps(accumulator_prefix)}, + status: 'running', + }}, + ], +}}); +process.stdout.write(JSON.stringify(scene.activity_rows.map(row => ({{ + role: row.role, + local_id: row.local_id, + text: row.text || '', +}})))); +""" + data = _run_node(MESSAGES_JS, script, tmp_path) + local_ids = [row["local_id"] for row in data] + # The stale accumulator snapshot (strict prefix of the final answer, well + # under the 0.9 near-match ratio) is dropped; narration and tools survive. + assert "live-prose:stream-multi:2" not in local_ids + assert "live-prose:stream-multi:1" in local_ids + assert data[0]["text"] == narration_text + assert any(row["role"] == "tool" for row in data)