mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
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:
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user