Merge remote-tracking branch 'origin/master' into HEAD
This commit is contained in:
commit
636a543853
3 files changed
+177
-1
No files matched your search
@@ -5,6 +5,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Custom-provider models keep their exact identity across catalog refreshes.** When a session used a model served through a custom provider whose upstream id contains a namespace (e.g. `z-ai/glm-5.2` under a `tokenrouter` custom endpoint), a catalog refresh could mis-resolve the picker selection — matching a *different* model whose bare suffix collided, or (in an interim fix) fabricating a new bare-id option instead of re-selecting the real one. Model resolution now matches the exact routed id first, and only falls back to a provider-hinted bare-id repair (preserving the older #6195 behavior for legacy sessions that stored just the suffix) when there is exactly one unambiguous candidate — otherwise it declines to guess. The resolved value is always an existing dropdown option, so re-renders no longer inject duplicates or lose the selection. Thanks @josephcy95. (#6944)
|
||||
- **WebUI no longer crashes a send when running against an older hermes-agent.** The streaming path passed `persist_user_timestamp=` to `agent.run_conversation()` unconditionally, so a WebUI paired with an older agent whose `run_conversation()` predates that keyword raised `TypeError: unexpected keyword argument` and the message failed. The three call sites now check the callable's signature first (via a small `_supports_kwarg` introspection helper) and only pass the timestamp when the agent accepts it — the same defensive pattern already used for `moa_config`. Modern agents are unaffected (the timestamp is still passed with the identical value); older agents degrade gracefully instead of erroring. Thanks @harsh2hell. (#6935)
|
||||
- **`hermes sessions list` no longer shows blank titles for WebUI sessions.** Background title generation wrote the auto-title to the WebUI session sidecar but never to hermes-agent's `state.db`, so the CLI/TUI session list showed those WebUI sessions untitled. The title is now bridged into `state.db` after it's generated (and on adaptive refresh) via `set_auto_title_if_empty`, which respects title provenance — a name you set manually via CLI/Gateway/TUI (`user` source) is never overwritten by the auto-title, an already-auto-titled row stays put, and an existing session's `source` is preserved (the sync creates the row with INSERT-OR-IGNORE semantics). Thanks @liuguangyong93. (#6892)
|
||||
- **No more phantom "Compressing context" barrier after switching sessions.** The compression UI state is per-session, but `loadSession()` did not clear it on a switch, so a compression card from a prior session could leak across the load and appear as a phantom "Compressing context" barrier on a fresh session that never triggered compression. Switching sessions now clears the compression UI (state, elapsed timer, composer session-lock, and barrier element) alongside the other per-session resets, before the transcript loads. A compression genuinely running on the session you switch *to* is unaffected — it re-surfaces from that session's own live event stream (and manual compression from its server job status). Thanks @happy5318. (#6938)
|
||||
|
||||
+28
-1
@@ -3195,7 +3195,34 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){
|
||||
}
|
||||
const preferred=String(preferredProviderId||explicitProvider||'').toLowerCase();
|
||||
if(preferred){
|
||||
const providerMatch=options.find(o=>norm(o.value)===target && _getOptionProviderId(o).toLowerCase()===preferred);
|
||||
if(preferred==='custom'||preferred.startsWith('custom:')){
|
||||
// A slash is part of a custom endpoint's upstream model ID, not a
|
||||
// provider namespace. Match the exact routed ID (allowing only the
|
||||
// WebUI's @provider: wrapper and dash/dot spelling compatibility).
|
||||
const routeNorm=value=>{
|
||||
let routed=String(value||'');
|
||||
const prefix=`@${preferred}:`;
|
||||
if(routed.toLowerCase().startsWith(prefix)) routed=routed.slice(prefix.length);
|
||||
return routed.toLowerCase().replace(/-/g,'.');
|
||||
};
|
||||
const providerOptions=options.filter(o=>_getOptionProviderId(o).toLowerCase()===preferred);
|
||||
const providerMatch=providerOptions.find(o=>routeNorm(o.value)===routeNorm(rawModel));
|
||||
if(providerMatch) return providerMatch.value;
|
||||
// Legacy sessions may store only the bare suffix of a routed custom
|
||||
// option. Preserve #6195's provider-hinted repair, but only for an
|
||||
// explicit @provider: row; an unwrapped slash ID belongs to the active
|
||||
// endpoint and must not substitute for a distinct bare model.
|
||||
if(!rawModel.includes('/')&&!rawModel.startsWith('@')){
|
||||
const prefix=`@${preferred}:`;
|
||||
const suffixMatches=providerOptions.filter(o=>
|
||||
String(o.value||'').toLowerCase().startsWith(prefix)
|
||||
&&norm(o.value)===target
|
||||
);
|
||||
if(suffixMatches.length===1) return suffixMatches[0].value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const providerMatch=options.find(o=>norm(o.value)===target&&_getOptionProviderId(o).toLowerCase()===preferred);
|
||||
if(providerMatch) return providerMatch.value;
|
||||
}
|
||||
// 2. Normalized match — but ONLY when unambiguous. If the bare id
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Regression coverage for exact custom-provider model identity during catalog refresh."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = REPO_ROOT / "static" / "ui.js"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
_DRIVER = r"""
|
||||
const fs=require('fs');
|
||||
const src=fs.readFileSync(process.argv[2],'utf8');
|
||||
function extract(name){
|
||||
const start=src.indexOf('function '+name+'(');
|
||||
if(start<0) throw new Error('missing '+name);
|
||||
let i=src.indexOf('{',start), depth=0;
|
||||
for(;i<src.length;i++){
|
||||
if(src[i]==='{') depth++;
|
||||
else if(src[i]==='}'&&--depth===0) return src.slice(start,i+1);
|
||||
}
|
||||
throw new Error('unterminated '+name);
|
||||
}
|
||||
class Node {
|
||||
constructor(tag){this.tagName=tag.toUpperCase();this.children=[];this.dataset={};this.parentElement=null;this._value='';this.textContent='';this.id='';}
|
||||
appendChild(child){child.parentElement=this;this.children.push(child);return child;}
|
||||
removeChild(child){this.children=this.children.filter(x=>x!==child);child.parentElement=null;}
|
||||
querySelectorAll(selector){
|
||||
if(selector==='optgroup') return this.children.filter(x=>x.tagName==='OPTGROUP');
|
||||
return [];
|
||||
}
|
||||
get options(){
|
||||
if(this.tagName!=='SELECT') return [];
|
||||
return this.children.flatMap(x=>x.tagName==='OPTGROUP'?x.children:[x]).filter(x=>x.tagName==='OPTION');
|
||||
}
|
||||
get value(){return this._value;}
|
||||
set value(value){this._value=String(value||'');}
|
||||
get selectedOptions(){const hit=this.options.find(x=>x.value===this._value);return hit?[hit]:[];}
|
||||
}
|
||||
const document={createElement:tag=>new Node(tag)};
|
||||
const window={_configuredModelBadges:{},_activeProvider:'custom:cpa'};
|
||||
const S={session:null};
|
||||
const _dynamicModelLabels={};
|
||||
const _liveModelFetchPending=new Set();
|
||||
const $=()=>null;
|
||||
const getModelLabel=value=>value;
|
||||
const syncModelChip=()=>{};
|
||||
for(const name of ['_getOptionProviderId','_providerFromModelValue','_modelPickerOptionIdentity','_deduplicateModelPickerOptions','_modelStateForSelect','_findModelInDropdown','_refreshOpenModelDropdown','_applyModelToDropdown','_ensureModelOptionInDropdown','_addLiveModelsToSelect']) eval(extract(name));
|
||||
function makeSelect(selected){
|
||||
const sel=new Node('select');sel.id='modelSelect';
|
||||
const group=new Node('optgroup');group.dataset.provider='custom:cpa';sel.appendChild(group);
|
||||
for(const id of ['jb/gpt-5.6-sol']){const opt=new Node('option');opt.value=id;opt.dataset.provider='custom:cpa';group.appendChild(opt);}
|
||||
sel.value=selected||'jb/gpt-5.6-sol';return sel;
|
||||
}
|
||||
const bare=makeSelect();
|
||||
const appliedBare=_ensureModelOptionInDropdown('gpt-5.6-sol',bare,'custom:cpa');
|
||||
const beforeLive=_modelStateForSelect(bare,bare.value);
|
||||
_addLiveModelsToSelect('custom:cpa',[{id:'gpt-5.6-sol'},{id:'jb/gpt-5.6-sol'}],bare);
|
||||
const afterLive=_modelStateForSelect(bare,bare.value);
|
||||
const qualified=makeSelect('jb/gpt-5.6-sol');
|
||||
_addLiveModelsToSelect('custom:cpa',[{id:'gpt-5.6-sol'}],qualified);
|
||||
console.log(JSON.stringify({
|
||||
appliedBare,beforeLive,afterLive,
|
||||
bareOptions:bare.options.map(x=>x.value),
|
||||
qualifiedAfter:_modelStateForSelect(qualified,qualified.value),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not in PATH")
|
||||
def test_partial_then_live_catalog_preserves_exact_custom_provider_model(tmp_path):
|
||||
driver = tmp_path / "driver.js"
|
||||
driver.write_text(_DRIVER, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[NODE, str(driver), str(UI_JS)], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
expected = {"model": "gpt-5.6-sol", "model_provider": "custom:cpa"}
|
||||
assert payload["beforeLive"] == expected
|
||||
assert payload["afterLive"] == expected
|
||||
assert payload["appliedBare"] == "@custom:cpa:gpt-5.6-sol"
|
||||
assert "@custom:cpa:gpt-5.6-sol" in payload["bareOptions"]
|
||||
assert "@custom:cpa:jb/gpt-5.6-sol" in payload["bareOptions"]
|
||||
assert payload["qualifiedAfter"] == {
|
||||
"model": "jb/gpt-5.6-sol",
|
||||
"model_provider": "custom:cpa",
|
||||
}
|
||||
|
||||
|
||||
# Ambiguity guard for the #6195 bare-id fallback (Codex round-2 SHOULD-FIX):
|
||||
# when two options under the same custom provider normalize to the same target,
|
||||
# the hinted-fallback must refuse to guess and return null rather than pick
|
||||
# an arbitrary one.
|
||||
_AMBIGUITY_DRIVER = r"""
|
||||
const fs=require('fs');
|
||||
const src=fs.readFileSync(process.argv[2],'utf8');
|
||||
function extract(name){
|
||||
const start=src.indexOf('function '+name+'(');
|
||||
if(start<0) throw new Error('missing '+name);
|
||||
let i=src.indexOf('{',start), depth=0;
|
||||
for(;i<src.length;i++){
|
||||
if(src[i]==='{') depth++;
|
||||
else if(src[i]==='}'&&--depth===0) return src.slice(start,i+1);
|
||||
}
|
||||
throw new Error('unterminated '+name);
|
||||
}
|
||||
function _getOptionProviderId(o){return o.providerId||'';}
|
||||
for(const name of ['_findModelInDropdown']) eval(extract(name));
|
||||
// Single unambiguous suffix match -> resolves to the existing namespaced option.
|
||||
const single={options:[
|
||||
{value:'@custom:tok:z-ai/glm-5.2', providerId:'custom:tok'},
|
||||
{value:'openai/gpt-4', providerId:'openai'},
|
||||
]};
|
||||
// Two same-provider options normalizing to the same target -> ambiguous.
|
||||
const ambiguous={options:[
|
||||
{value:'@custom:tok:z-ai/glm-5.2', providerId:'custom:tok'},
|
||||
{value:'@custom:tok:other/glm-5.2', providerId:'custom:tok'},
|
||||
]};
|
||||
console.log(JSON.stringify({
|
||||
single:_findModelInDropdown('glm-5.2', single, 'custom:tok'),
|
||||
ambiguous:_findModelInDropdown('glm-5.2', ambiguous, 'custom:tok'),
|
||||
exact:_findModelInDropdown('z-ai/glm-5.2', single, 'custom:tok'),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not in PATH")
|
||||
def test_hinted_bare_id_fallback_refuses_ambiguous_match(tmp_path):
|
||||
driver = tmp_path / "amb_driver.js"
|
||||
driver.write_text(_AMBIGUITY_DRIVER, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[NODE, str(driver), str(UI_JS)], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
|
||||
# One unambiguous suffix match: the bare id resolves to the existing
|
||||
# namespaced catalog option (#6195 repair preserved).
|
||||
assert payload["single"] == "@custom:tok:z-ai/glm-5.2"
|
||||
# Two same-normalized options under the same provider: refuse to guess.
|
||||
assert payload["ambiguous"] is None
|
||||
# Exact routed match is unaffected by the fallback path (#6944 fix intact).
|
||||
assert payload["exact"] == "@custom:tok:z-ai/glm-5.2"
|
||||
Reference in new issue
Block a user