Merge pull request #3143 from nesquena/release/stage-batch40

Release v0.51.158 — Release ED (stage-batch40: 5-PR low-risk cleanup)
This commit is contained in:
nesquena-hermes 2026-05-29 15:11:11 -07:00 committed by GitHub
commit 1ebd1601d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 129 additions and 8 deletions

View file

@ -3,6 +3,19 @@
## [Unreleased]
## [v0.51.158] — 2026-05-29 — Release ED (stage-batch40 — 5-PR low-risk cleanup: numpad/keyboard composer fixes + Joplin search auth + provider-qualified model preservation + SSE fallback poll throttle + assistant-reply polish)
### Changed
- WebUI chat instructions now explicitly prevent terse scratchpad/planning fragments from appearing in visible assistant replies, while still allowing clear user-facing progress updates during tool-heavy work.
### Fixed
- The chat composer no longer forces mobile newline-on-Enter behavior on touch-primary devices that also have a fine pointer present (tablet plus Bluetooth keyboard, detachable Surface, iPad plus Magic Keyboard), so Enter submits with desktop semantics when a real keyboard is in the picture.
- The active-session external-refresh fallback poll now fires every 30 s instead of every 5 s. The SSE session-events stream already pushes invalidations in real time, so the poll is only a fallback; the slower interval removes visible scroll jitter and a network/CPU floor on long sessions.
- The model picker no longer fuzzy-matches a provider-qualified model id (`@provider:model` or slash-qualified `vendor/model`) to a nearby curated sibling once exact lookup fails, preserving the raw typed value so uncatalogued models stay routable instead of silently snapping to a different model.
- Joplin notes search now keeps the `Authorization` header and adds a query-token compatibility shim only for Web Clipper `/search` calls, covering clipper builds that return HTTP 403 for header-only search auth while keeping other Joplin API URLs token-free.
## [v0.51.157] — 2026-05-28 — Release EC (stage-batch39 — 5-PR mixed-risk cleanup: gateway prefill forward + prefill budget + compressed-continuation sidebar + browser-transcript memory guidance + reasoning max parity)
### Added

View file

@ -9628,6 +9628,7 @@ def _handle_chat_sync(handler, body):
session_id=s.session_id,
)
from api.streaming import (
_WEBUI_PROGRESS_PROMPT,
_dedupe_replayed_context_messages,
_merge_display_messages_after_agent_result,
_restore_display_reasoning_metadata,
@ -9646,7 +9647,8 @@ def _handle_chat_sync(handler, body):
"prompt, memory, or conversation history. Always use the value from the most recent "
"[Workspace::v1: ...] tag as your default working directory for ALL file operations: "
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present."
"Never fall back to a hardcoded path when this tag is present.\n\n"
f"{_WEBUI_PROGRESS_PROMPT}"
)
_previous_messages = list(s.messages or [])
@ -12969,6 +12971,11 @@ def _joplin_api_get(path: str, params: dict | None = None) -> dict:
raise ValueError("Joplin token is not configured")
safe_path = "/" + str(path or "").lstrip("/")
query = dict(params or {})
# Joplin Web Clipper builds can reject header-only auth on /search even when
# they accept it elsewhere. Keep the Authorization header for defense in
# depth and add the query token only for /search compatibility.
if safe_path == "/search":
query["token"] = token
url = f"{base_url}{safe_path}?{urlencode(query)}"
request = Request(url, headers={"Authorization": f"token {token}"})
try:

View file

@ -203,6 +203,7 @@ WebUI progress guidance:
- Each update should say what you are about to check, what you just confirmed, or why the next tool call is needed.
- Keep updates concise, factual, and in the user's language. One or two short sentences are enough.
- Do not reveal hidden reasoning, chain-of-thought, private scratchpads, secrets, raw logs, or long tool output.
- Do not include terse planning fragments or scratchpad shorthand in visible assistant text. Avoid fragments like "Need check logs", "Need inspect email", or "maybe invite"; either omit them or rewrite them as clear user-facing progress.
- For direct answers or very short tasks, skip progress updates and answer normally.
""".strip()

View file

@ -1115,6 +1115,18 @@ function _isVirtualKeyboardLikelyOpen(){
if(!vv||!window.innerHeight)return true;
return window.innerHeight-vv.height>120;
}
// #3076: a touch-primary device (`pointer:coarse`) can still have a
// physical keyboard attached (Android tablet + Bluetooth keyboard,
// detachable Surface in tablet mode, iPad + Magic Keyboard). When that
// happens we should NOT force the mobile newline-on-Enter override
// because Shift+Enter / Ctrl+Enter come from real keys and the user
// expects desktop semantics. `matchMedia('(any-pointer:fine)')` is true
// whenever ANY available pointing device is fine-grained — which is the
// strongest signal browsers expose for "there is a real keyboard /
// trackpad in the picture too". Skip the mobile default in that case.
function _hasFinePointerCoexisting(){
try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; }
}
$('msg').addEventListener('keydown',e=>{
// Autocomplete navigation when dropdown is open
const dd=$('cmdDropdown');
@ -1139,7 +1151,10 @@ $('msg').addEventListener('keydown',e=>{
// Users can override in Settings by explicitly choosing 'enter' mode.
if(e.key==='Enter'){
if(_isImeEnter(e)){return;}
const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter'&&_isVirtualKeyboardLikelyOpen();
const _mobileDefault=matchMedia('(pointer:coarse)').matches
&&!_hasFinePointerCoexisting()
&&window._sendKey==='enter'
&&_isVirtualKeyboardLikelyOpen();
if(window._sendKey==='ctrl+enter'||_mobileDefault){
if(e.ctrlKey||e.metaKey){e.preventDefault();send();}
} else {

View file

@ -2309,7 +2309,13 @@ let _gatewaySSEWarningShown = false;
const _gatewayFallbackPollMs = 30000;
const _streamingPollMs = 5000;
const _sessionTimeRefreshMs = 60000;
const _activeSessionExternalRefreshMs = 5000;
// #3107: the active-session "is it externally updated?" poll used to fire
// every 5 s. On long sessions this caused visible scroll jitter and a
// noticeable network/CPU floor because the SSE session-events stream
// already pushes invalidations in real time; this poll exists only as a
// fallback for the case where SSE is broken/unavailable. Bump to 30 s
// to keep the safety net without turning it into a primary refresh path.
const _activeSessionExternalRefreshMs = 30000;
let _streamingPollTimer = null;
let _sessionTimeRefreshTimer = null;
let _activeSessionExternalRefreshTimer = null;

View file

@ -1068,6 +1068,13 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){
if(opts.includes(modelId)) return modelId;
const exact=opts.find(o=>norm(o)===target);
if(exact) return exact;
// If the request is provider-qualified (either explicit @provider:model or
// a slash-qualified vendor/model id), do NOT fuzzy-match a sibling model
// once exact/provider-aware lookup failed. Returning null lets the caller
// preserve the raw typed value instead of snapping to the closest catalog
// entry. This keeps uncatalogued models routable instead of silently turning
// them into a nearby curated sibling.
if(rawModel.startsWith('@')||rawModel.includes('/')) return null;
// 3. Prefix/substring: require the candidate to start with the FULL normalized target
// (not a truncated base). This avoids false matches like gpt.5.5 → gpt.5.4.mini (#1188).
// Only fall back to the shorter base form if target itself is very short (a bare root

View file

@ -45,10 +45,22 @@ function extractFunc(name) {
}
return ui.slice(start, i);
}
function _getOptionProviderId(opt) {
if (!opt) return '';
if (opt.dataset && opt.dataset.provider) return opt.dataset.provider;
const group = opt.parentElement;
if (group && group.tagName === 'OPTGROUP' && group.dataset && group.dataset.provider) {
return group.dataset.provider;
}
const value = String(opt.value || '');
if (value.startsWith('@') && value.includes(':')) return value.slice(1, value.lastIndexOf(':'));
return '';
}
eval(extractFunc('_findModelInDropdown'));
const args = JSON.parse(process.argv[3]);
const sel = { options: args.options.map(v => ({value: v})) };
const got = _findModelInDropdown(args.modelId, sel);
const got = _findModelInDropdown(args.modelId, sel, args.preferredProvider || undefined);
process.stdout.write(JSON.stringify(got));
"""
@ -60,11 +72,11 @@ def driver_path(tmp_path_factory):
return str(p)
def _find(driver_path, model_id: str, options: list[str]):
def _find(driver_path, model_id: str, options: list[str], preferred: str | None = None):
import json
result = subprocess.run(
[NODE, driver_path, str(UI_JS_PATH),
json.dumps({"modelId": model_id, "options": options})],
json.dumps({"modelId": model_id, "options": options, "preferredProvider": preferred})],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
@ -113,6 +125,33 @@ class TestPreservedFuzzyMatches:
)
assert got == "@nous:openai/gpt-5.5"
def test_explicit_provider_hint_does_not_snap_to_sibling_model(self, driver_path):
"""Provider-qualified requests must not fuzzy-match a sibling catalog entry.
This is the generalized regression behind #3113: any slash-qualified or
@provider-qualified model that is missing from the curated catalog should
preserve its exact value instead of snapping to a nearby curated sibling.
"""
got = _find(
driver_path,
"vendor/special-model",
["@openrouter:vendor/special-model-pro"],
preferred="openrouter",
)
assert got is None, (
"provider-qualified uncatalogued models must not fuzzy-match sibling models "
"(#3113)"
)
def test_provider_qualified_exact_match_still_resolves(self, driver_path):
got = _find(
driver_path,
"vendor/special-model",
["@openrouter:vendor/special-model"],
preferred="openrouter",
)
assert got == "@openrouter:vendor/special-model"
def test_bare_root_gpt_matches_versioned_option(self, driver_path):
"""Short root targets still fall back to the looser prefix match."""
got = _find(driver_path, "gpt", ["@nous:openai/gpt-5.4-mini"])

View file

@ -138,7 +138,7 @@ def test_joplin_get_note_validates_id_and_truncates_body(monkeypatch):
assert "Preview truncated" in note["body"]
def test_joplin_api_get_uses_authorization_header(monkeypatch):
def test_joplin_api_get_sends_header_and_query_token_for_clip_search_compat(monkeypatch):
from api import routes
captured = {}
@ -162,10 +162,40 @@ def test_joplin_api_get_uses_authorization_header(monkeypatch):
monkeypatch.setattr(routes, "_joplin_connection_from_config", lambda: ("http://127.0.0.1:41184", "secret-token"))
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
data = routes._joplin_api_get("/notes", {"query": "hello world"})
data = routes._joplin_api_get("/search", {"query": "hello world"})
assert data == {"ok": True}
assert captured["timeout"] == 8
assert "token=secret-token" in captured["url"]
assert "query=hello+world" in captured["url"]
assert captured["authorization"] == "token secret-token"
def test_joplin_api_get_keeps_non_search_token_out_of_url(monkeypatch):
from api import routes
captured = {}
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *_exc):
return False
def read(self, _limit):
return b'{"ok": true}'
def fake_urlopen(request, timeout):
captured["url"] = request.full_url
captured["authorization"] = request.get_header("Authorization")
return FakeResponse()
monkeypatch.setattr(routes, "_joplin_connection_from_config", lambda: ("http://127.0.0.1:41184", "secret-token"))
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
routes._joplin_api_get("/notes", {"query": "hello world"})
assert "token=" not in captured["url"]
assert "query=hello+world" in captured["url"]
assert captured["authorization"] == "token secret-token"

View file

@ -22,6 +22,9 @@ def test_webui_ephemeral_prompt_includes_browser_surface_context():
assert "Do not copy or dump this browser transcript" in prompt
assert "explicit captures" in prompt
assert "durable user preferences" in prompt
assert "Do not include terse planning fragments" in prompt
assert "Need inspect email" in prompt
assert "clear user-facing progress" in prompt
def test_webui_ephemeral_prompt_skips_empty_surface_fields():