diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ab98180..d875695d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.277] — 2026-06-05 — Release IS (stage-p3f — preserve context-window in usage indicator) + +### Fixed +- **The context-window usage indicator no longer flips to the 128K/131.1k fallback after a turn completes.** A terminal or reload usage snapshot that arrived with missing/stale context metadata could replace already-resolved values; the indicator now merges usage snapshots field-by-field, preserving a known `context_length` (and other resolved usage fields) instead of overwriting them with nulls. (#3663, @franksong2702; fixes #3185, #3660) + ## [v0.51.276] — 2026-06-05 — Release IR (stage-p3e — preserve manually-named session titles) ### Fixed diff --git a/static/messages.js b/static/messages.js index 5915b3483..5dbe6004a 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2187,7 +2187,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Stamp _ts on the last assistant message if it has no timestamp if(lastAsst&&!lastAsst._ts&&!lastAsst.timestamp) lastAsst._ts=Date.now()/1000; if(d.usage){ - S.lastUsage=d.usage;_syncCtxIndicator(d.usage); + const _doneUsageFallback={...(S.lastUsage||{})}; + if(S.session){ + for(const _usageField of ['context_length','threshold_tokens','last_prompt_tokens']){ + if(_doneUsageFallback[_usageField]==null&&S.session[_usageField]!=null){ + _doneUsageFallback[_usageField]=S.session[_usageField]; + } + } + } + S.lastUsage=typeof _mergeUsageForCtxIndicator==='function' + ? _mergeUsageForCtxIndicator(d.usage,_doneUsageFallback) + : {..._doneUsageFallback,...d.usage}; + _syncCtxIndicator(S.lastUsage); // #503 — compute per-turn cost delta and attach to last assistant message if(lastAsst){ const prevIn=_prevIn; @@ -2389,7 +2400,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const displaySid=currentSid; const message=String(d.message||'Context auto-compressed to continue the conversation').trim(); if(d.usage&&typeof _syncCtxIndicator==='function'){ - S.lastUsage={...(S.lastUsage||{}),...d.usage}; + S.lastUsage=typeof _mergeUsageForCtxIndicator==='function' + ? _mergeUsageForCtxIndicator(d.usage,S.lastUsage||{}) + : {...(S.lastUsage||{}),...d.usage}; _syncCtxIndicator(S.lastUsage); } if(typeof setCompressionUi==='function'){ @@ -2425,7 +2438,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if((d.session_id||activeSid)!==activeSid) return; if(d.usage&&typeof _syncCtxIndicator==='function'){ if(S.session&&S.session.session_id===activeSid){ - S.lastUsage={...(S.lastUsage||{}),...d.usage}; + S.lastUsage=typeof _mergeUsageForCtxIndicator==='function' + ? _mergeUsageForCtxIndicator(d.usage,S.lastUsage||{}) + : {...(S.lastUsage||{}),...d.usage}; _syncCtxIndicator(S.lastUsage); } } diff --git a/static/sessions.js b/static/sessions.js index 7d37edb9d..0c72c3c89 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1014,6 +1014,7 @@ async function loadSession(sid){ if(_s&&typeof _syncCtxIndicator==='function'){ const u=S.lastUsage||{}; const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt); + const _pickPositive=(latest,stored,dflt=0)=>Number(latest)>0?latest:(Number(stored)>0?stored:dflt); _syncCtxIndicator({ input_tokens: _pick(u.input_tokens, _s.input_tokens), output_tokens: _pick(u.output_tokens, _s.output_tokens), @@ -1021,7 +1022,7 @@ async function loadSession(sid){ cache_read_tokens: _pick(u.cache_read_tokens, _s.cache_read_tokens), cache_write_tokens:_pick(u.cache_write_tokens,_s.cache_write_tokens), cache_hit_percent: _pick(u.cache_hit_percent, _s.cache_hit_percent, null), - context_length: _pick(_s.context_length, u.context_length), + context_length: _pickPositive(u.context_length, _s.context_length), last_prompt_tokens:_pick(u.last_prompt_tokens,_s.last_prompt_tokens), threshold_tokens: _pick(_s.threshold_tokens, u.threshold_tokens), }); @@ -1451,7 +1452,8 @@ function _resolveSessionModelForDisplaySoon(sid){ if(!model||!S.session||S.session.session_id!==sid) return; S.session.model=model; S.session.model_provider=provider||null; - S.session.context_length=data.session.context_length||0; + const resolvedContextLength=data.session.context_length||S.session.context_length||0; + S.session.context_length=resolvedContextLength; S.session.threshold_tokens=data.session.threshold_tokens||0; S.session.last_prompt_tokens=data.session.last_prompt_tokens||0; S.session._modelResolutionDeferred=false; @@ -1466,7 +1468,7 @@ function _resolveSessionModelForDisplaySoon(sid){ cache_read_tokens:_pick(u.cache_read_tokens,S.session.cache_read_tokens), cache_write_tokens:_pick(u.cache_write_tokens,S.session.cache_write_tokens), cache_hit_percent:_pick(u.cache_hit_percent,S.session.cache_hit_percent,null), - context_length:data.session.context_length||0, + context_length:resolvedContextLength||u.context_length||0, last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens), threshold_tokens:data.session.threshold_tokens||0, }); diff --git a/static/ui.js b/static/ui.js index 6a0ef70f4..49aae63c5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2753,6 +2753,30 @@ function _syncMobileCtxDisplay(state){ _setCtxCompressButton(compressBtn,state.compressText||''); } +function _mergeUsageForCtxIndicator(latest, fallback){ + const latestObj=(latest&&typeof latest==='object')?latest:{}; + const fallbackObj=(fallback&&typeof fallback==='object')?fallback:{}; + const merged={...latestObj}; + for(const field of [ + 'input_tokens','output_tokens','estimated_cost', + 'cache_read_tokens','cache_write_tokens','cache_hit_percent', + 'turn_cache_hit_percent','duration_seconds','tps','gateway_routing', + ]){ + if(merged[field]==null&&fallbackObj[field]!=null){ + merged[field]=fallbackObj[field]; + } + } + if(!(Number(latestObj.context_length)>0)&&Number(fallbackObj.context_length)>0){ + merged.context_length=fallbackObj.context_length; + } + for(const field of ['threshold_tokens','last_prompt_tokens']){ + if(latestObj[field]==null&&fallbackObj[field]!=null){ + merged[field]=fallbackObj[field]; + } + } + return merged; +} + // Context usage indicator in composer footer function _syncCtxIndicator(usage){ const wrap=$('ctxIndicatorWrap'); diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index 5f59faddf..7841ca13b 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -266,7 +266,7 @@ def test_auto_compression_done_sse_refreshes_context_indicator_usage(): block = _compressed_listener_block() assert "if(d.usage&&typeof _syncCtxIndicator==='function')" in block - assert "S.lastUsage={...(S.lastUsage||{}),...d.usage};" in block + assert "_mergeUsageForCtxIndicator(d.usage,S.lastUsage||{})" in block assert "_syncCtxIndicator(S.lastUsage);" in block assert block.index("_syncCtxIndicator(S.lastUsage);") < block.index("setCompressionUi") diff --git a/tests/test_issue1617_tps_message_header.py b/tests/test_issue1617_tps_message_header.py index 814d2ea71..86518f53a 100644 --- a/tests/test_issue1617_tps_message_header.py +++ b/tests/test_issue1617_tps_message_header.py @@ -53,7 +53,10 @@ def test_live_metering_usage_is_provisional_until_done(): assert listener_end != -1, "apperror listener should follow metering listener" listener = MESSAGES_JS[listener_start:listener_end] - assert "S.lastUsage={...(S.lastUsage||{}),...d.usage}" in listener, ( + assert "_mergeUsageForCtxIndicator(d.usage,S.lastUsage||{})" in listener, ( + "live usage should merge through the context indicator usage guard" + ) + assert ": {...(S.lastUsage||{}),...d.usage}" in listener, ( "live usage should update the transient usage cache for the indicator" ) assert "_syncCtxIndicator(S.lastUsage)" in listener, ( diff --git a/tests/test_issue3660_context_window_stale.py b/tests/test_issue3660_context_window_stale.py new file mode 100644 index 000000000..3c0236403 --- /dev/null +++ b/tests/test_issue3660_context_window_stale.py @@ -0,0 +1,64 @@ +"""Regression coverage for #3660/#3185 stale context-window indicators. + +The live SSE metering path can know the real model context window while the +terminal `done` snapshot or cold session snapshot omits that field. The UI must +not replace a known 1M window with the JavaScript 128K fallback on settlement or +history reload. +""" +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def _listener_block(event_name: str, next_event_name: str) -> str: + start = MESSAGES_JS.find(f"source.addEventListener('{event_name}'") + assert start != -1, f"{event_name} listener not found" + end = MESSAGES_JS.find(f"source.addEventListener('{next_event_name}'", start) + assert end != -1, f"{next_event_name} listener after {event_name} not found" + return MESSAGES_JS[start:end] + + +def test_done_event_preserves_last_known_context_window(): + block = _listener_block("done", "stream_end") + + assert "S.lastUsage=d.usage;_syncCtxIndicator(d.usage);" not in block + assert "const _doneUsageFallback={...(S.lastUsage||{})};" in block + assert "_doneUsageFallback[_usageField]=S.session[_usageField];" in block + assert "_mergeUsageForCtxIndicator(d.usage,_doneUsageFallback)" in block + assert "_syncCtxIndicator(S.lastUsage);" in block + + +def test_usage_merge_helper_keeps_context_when_latest_omits_it(): + start = UI_JS.find("function _mergeUsageForCtxIndicator") + assert start != -1, "context usage merge helper not found" + end = UI_JS.find("// Context usage indicator in composer footer", start) + assert end != -1, "context usage merge helper end marker not found" + block = UI_JS[start:end] + + assert "const merged={...latestObj};" in block + assert "'input_tokens','output_tokens','estimated_cost'," in block + assert "if(!(Number(latestObj.context_length)>0)&&Number(fallbackObj.context_length)>0)" in block + assert "merged.context_length=fallbackObj.context_length;" in block + assert "threshold_tokens" in block + assert "last_prompt_tokens" in block + + +def test_session_load_prefers_positive_last_usage_context_over_stale_snapshot(): + assert "const _pickPositive=(latest,stored,dflt=0)=>Number(latest)>0?latest:(Number(stored)>0?stored:dflt);" in SESSIONS_JS + assert "context_length: _pickPositive(u.context_length, _s.context_length)," in SESSIONS_JS + + +def test_deferred_model_resolve_does_not_zero_out_existing_context_window(): + block_start = SESSIONS_JS.find("function _resolveSessionModelForDisplaySoon") + assert block_start != -1, "deferred model resolver not found" + block_end = SESSIONS_JS.find("// Tracks whether the current session has older messages", block_start) + assert block_end != -1, "deferred model resolver end marker not found" + block = SESSIONS_JS[block_start:block_end] + + assert "const resolvedContextLength=data.session.context_length||S.session.context_length||0;" in block + assert "S.session.context_length=resolvedContextLength;" in block + assert "context_length:resolvedContextLength||u.context_length||0," in block diff --git a/tests/test_session_metadata_fast_path.py b/tests/test_session_metadata_fast_path.py index 8ff2c08e2..c9567d4c8 100644 --- a/tests/test_session_metadata_fast_path.py +++ b/tests/test_session_metadata_fast_path.py @@ -54,7 +54,9 @@ def test_deferred_model_resolution_refreshes_context_metadata(): ) assert "S.session.threshold_tokens" in block assert "_syncCtxIndicator" in block - assert "context_length:data.session.context_length||0" in block.replace(" ", "") + compact = block.replace(" ", "") + assert "constresolvedContextLength=data.session.context_length||S.session.context_length||0;" in compact + assert "context_length:resolvedContextLength||u.context_length||0" in compact def test_boot_does_not_block_session_restore_on_model_catalog():