Release v0.51.277 — Release IS (stage-p3f — preserve context-window in usage indicator #3663) (#3683)

* fix(ui): preserve resolved context window in usage indicator (#3663, #3185, #3660)

Co-authored-by: Frank Song <franksong2702@gmail.com>

* docs(changelog): v0.51.277 — Release IS (stage-p3f)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Frank Song <franksong2702@gmail.com>
This commit is contained in:
nesquena-hermes
2026-06-05 13:45:15 -07:00
committed by GitHub
parent 87084dfebf
commit 8ef698ea05
8 changed files with 124 additions and 9 deletions
+5
View File
@@ -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
+18 -3
View File
@@ -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);
}
}
+5 -3
View File
@@ -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,
});
+24
View File
@@ -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');
+1 -1
View File
@@ -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")
+4 -1
View File
@@ -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, (
@@ -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
+3 -1
View File
@@ -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():