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:
commit
1ebd1601d6
9 changed files with 129 additions and 8 deletions
13
CHANGELOG.md
13
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue