Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 16 additions & 2 deletions static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<finalKey.length&&finalKey.startsWith(textKey);
const pushRow=(row,rowIndex)=>{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The rowIndex parameter is no longer read inside pushRow — finalSegmentEligible is now computed from the WeakSet before row is reassigned, so the index passed by forEach goes unused. Removing it avoids leaving a misleading vestige of the old boundary logic.

Suggested change
const pushRow=(row,rowIndex)=>{
const pushRow=(row)=>{

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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;
Expand Down
10 changes: 8 additions & 2 deletions tests/test_5749_anchor_scene_client_prefix_dedupe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions tests/test_issue5749_transparent_stream_prefix_dedupe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading