diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ae2aa19eb..46c99b826b5 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 cd763ec00e2..6f340e38bb8 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 31058e966c7..ee42e477399 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 b12c6c682c5..cb37358cc1a 100644 --- a/tests/test_issue5749_transparent_stream_prefix_dedupe.py +++ b/tests/test_issue5749_transparent_stream_prefix_dedupe.py @@ -672,3 +672,140 @@ class FakeElement {{ {"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)