diff --git a/CHANGELOG.md b/CHANGELOG.md index eefa3790..375c86e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/tests/test_custom_provider_model_identity.py b/tests/test_custom_provider_model_identity.py index 063b4fb3..e9187fe2 100644 --- a/tests/test_custom_provider_model_identity.py +++ b/tests/test_custom_provider_model_identity.py @@ -90,3 +90,59 @@ def test_partial_then_live_catalog_preserves_exact_custom_provider_model(tmp_pat "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 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"