diff --git a/CHANGELOG.md b/CHANGELOG.md index 93da0a58..bee126ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/routes.py b/api/routes.py index 768b499b..7195b672 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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: diff --git a/api/streaming.py b/api/streaming.py index 70087bd5..980d1455 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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() diff --git a/static/boot.js b/static/boot.js index 072171b5..23a90cac 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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 { diff --git a/static/sessions.js b/static/sessions.js index 8f3ec00c..fc90f1f5 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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; diff --git a/static/ui.js b/static/ui.js index c3624986..4570412a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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 diff --git a/tests/test_issue1188_fuzzy_match.py b/tests/test_issue1188_fuzzy_match.py index 5e4201eb..1b5697e8 100644 --- a/tests/test_issue1188_fuzzy_match.py +++ b/tests/test_issue1188_fuzzy_match.py @@ -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"]) diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py index 00671d35..bac05eb5 100644 --- a/tests/test_webui_notes_sources.py +++ b/tests/test_webui_notes_sources.py @@ -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" diff --git a/tests/test_webui_surface_context.py b/tests/test_webui_surface_context.py index 562347c5..89f65040 100644 --- a/tests/test_webui_surface_context.py +++ b/tests/test_webui_surface_context.py @@ -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():