From 4003e1c53da4aabf782f8ff6f6c8489309478d80 Mon Sep 17 00:00:00 2001 From: hamzamerzic <10846014+hamzamerzic@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:11:45 +0000 Subject: [PATCH 1/4] Align background agent pickers and defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Möbius Agent --- backend/app/providers.py | 84 +++++++++++++++---- backend/app/routes/auth.py | 7 +- backend/app/routes/settings.py | 6 +- backend/tests/test_auth.py | 23 +++-- backend/tests/test_model_registry.py | 28 ++++++- backend/tests/test_settings.py | 49 +++++++---- .../ProviderModelPicker.jsx | 2 + .../components/SettingsView/SettingsView.jsx | 69 +++++++++++++-- .../__tests__/settingsViewAppearance.test.js | 10 +++ 9 files changed, 216 insertions(+), 62 deletions(-) diff --git a/backend/app/providers.py b/backend/app/providers.py index baecbdede..637439d63 100644 --- a/backend/app/providers.py +++ b/backend/app/providers.py @@ -53,6 +53,8 @@ # source of truth for newly released IDs. KNOWN_MODELS = { "claude": [ + "claude-fable-5", + "claude-sonnet-5", # Anthropic switched to dateless pinned IDs starting with 4.6; # the dated entries below stay listed because existing chats # persist them in agent_settings_json and the API still resolves @@ -84,6 +86,8 @@ # Codex's models() returns slugs only), so labels come from this map # when present and fall back to the raw ID for newly released models. MODEL_LABELS: dict[str, str] = { + "claude-fable-5": "Fable 5", + "claude-sonnet-5": "Sonnet 5", "claude-opus-4-8": "Opus 4.8", "claude-opus-4-7": "Opus 4.7", "claude-opus-4-6": "Opus 4.6", @@ -107,8 +111,42 @@ # registry carries it to every shell/app picker as data. MODEL_EFFORT_LEVELS: dict[str, list[str]] = {} +# Runtime recovery defaults are intentionally independent of picker order. +# Fable is presented first in the interactive picker, but a stale/mismatched +# saved value must not silently opt an unattended retry into usage credits. DEFAULT_MODELS = { - provider: models[0] for provider, models in KNOWN_MODELS.items() + "claude": "claude-opus-4-8", + "codex": "gpt-5.6-sol", +} + +# Curated first-run model visibility. The registry remains broader so an +# existing chat can keep rendering an older saved model and the owner can +# reveal any hidden row from Settings. An explicit owner preference (including +# an explicit empty hidden list) always wins over this starter set. +DEFAULT_VISIBLE_MODEL_ORDER: dict[str, tuple[str, ...]] = { + "claude": ( + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + ), + "codex": ( + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + ), +} +DEFAULT_VISIBLE_MODELS: dict[str, frozenset[str]] = { + provider_id: frozenset(models) + for provider_id, models in DEFAULT_VISIBLE_MODEL_ORDER.items() +} + +# Unattended work gets deliberately conservative provider-specific defaults, +# independent of the first model shown in the interactive chat picker. +DEFAULT_BACKGROUND_MODELS = { + "claude": "claude-opus-4-8", + "codex": "gpt-5.6-terra", } # Initial effort when no global default exists. Aligns with the @@ -173,6 +211,23 @@ def _load_agent_settings(data_dir: str) -> dict: return {} +def hidden_model_ids(model_prefs: Any) -> list[str]: + """Resolve model-picker visibility for an owner. + + Missing preferences use the curated starter set above. Once the owner saves + Manage models, even ``{"hidden_ids": []}`` is explicit and means show all. + """ + if isinstance(model_prefs, dict) and "hidden_ids" in model_prefs: + raw = model_prefs.get("hidden_ids") + return [entry for entry in (raw or []) if isinstance(entry, str)] + return [ + model_id + for provider_id, models in KNOWN_MODELS.items() + for model_id in models + if model_id not in DEFAULT_VISIBLE_MODELS.get(provider_id, frozenset()) + ] + + def skills_enabled(data_dir: str) -> bool: """Whether SDK skills are offered to the Claude agent (default OFF). @@ -304,7 +359,7 @@ def _background_default_choice( ) -> dict: return { "provider": provider, - "model": model, + "model": model if model is not None else DEFAULT_BACKGROUND_MODELS.get(provider), "effort": DEFAULT_EFFORT, "enabled": enabled, } @@ -328,14 +383,14 @@ def _clean_background_choice( if isinstance(raw_model, str) and raw_model.strip(): model = raw_model.strip() if _model_belongs_to_other_provider(model, provider): - model = DEFAULT_MODELS.get(provider) + model = DEFAULT_BACKGROUND_MODELS.get(provider) elif "model" in raw: # Explicit null/empty means "let this provider use its native default". model = None else: # Legacy provider-only choices predate nullable model defaults; keep them # concrete so background runners do not inherit the chat model by accident. - model = DEFAULT_MODELS.get(provider) + model = DEFAULT_BACKGROUND_MODELS.get(provider) out["model"] = model effort = raw.get("effort") out["effort"] = effort.strip() if isinstance(effort, str) and effort.strip() else None @@ -369,14 +424,6 @@ def background_agent_settings(data_dir: str, default_provider: str | None = None file_layer = _load_agent_settings(data_dir) raw = file_layer.get("background_agents") bg = raw if isinstance(raw, dict) else {} - # When the owner has already picked a chat model, synthesize a concrete - # provider-native background model rather than inheriting that chat default. - # With no manual model choice at all, keep the background model nullable so - # the provider SDK can use its own default until the owner saves a row. - synthetic_default_model = ( - DEFAULT_MODELS.get(provider) if "model" in file_layer else None - ) - rows: list[dict[str, Any]] = [] seen: set[str] = set() @@ -410,7 +457,6 @@ def add_row(choice: dict | None, *, enabled_default: bool) -> None: primary = _background_default_choice( provider, enabled=True, - model=synthetic_default_model, ) add_row(primary, enabled_default=True) add_row(_clean_background_choice(bg.get("fallback")), enabled_default=True) @@ -420,7 +466,6 @@ def add_row(choice: dict | None, *, enabled_default: bool) -> None: _background_default_choice( provider, enabled=True, - model=synthetic_default_model, ), enabled_default=True, ) @@ -431,7 +476,7 @@ def add_row(choice: dict | None, *, enabled_default: bool) -> None: _background_default_choice( provider_id, enabled=False, - model=DEFAULT_MODELS.get(provider_id), + model=DEFAULT_BACKGROUND_MODELS.get(provider_id), ) ) @@ -835,6 +880,13 @@ def _live_model_entries( succeeds, the provider SDK/CLI is the source of truth; labels are a cosmetic map with raw-ID fallback. """ + # The curated compatibility aliases are an owner-chosen product surface, not + # a mirror of one catalog response. Keep them available even when a provider + # temporarily omits an older-but-still-supported alias (Sonnet 4.6 / GPT-5.5) + # from discovery, then append every genuinely live extra in provider order. + preferred = DEFAULT_VISIBLE_MODEL_ORDER.get(provider_id, ()) + ordered_ids = list(preferred) + ordered_ids.extend(model_id for model_id in live_ids if model_id not in preferred) return [ { "id": mid, @@ -844,7 +896,7 @@ def _live_model_entries( **({"effort_levels": MODEL_EFFORT_LEVELS[mid]} if mid in MODEL_EFFORT_LEVELS else {}), } - for mid in live_ids + for mid in ordered_ids ] diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 69ad69e11..fa68ff36e 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -622,11 +622,8 @@ async def providers_models( from app.providers import list_models data_dir = get_settings().data_dir registry = await list_models(data_dir) - prefs = owner.model_prefs_json or {} - hidden_ids = { - entry for entry in (prefs.get("hidden_ids") or []) - if isinstance(entry, str) - } + from app.providers import hidden_model_ids + hidden_ids = set(hidden_model_ids(owner.model_prefs_json)) out: dict[str, list[dict[str, str]]] = {} for provider_id, entries in registry.items(): rows: list[dict[str, str]] = [] diff --git a/backend/app/routes/settings.py b/backend/app/routes/settings.py index 4aa82d6f7..1c876ddc8 100644 --- a/backend/app/routes/settings.py +++ b/backend/app/routes/settings.py @@ -376,11 +376,7 @@ def get_model_prefs( Default shape is `{"hidden_ids": []}` — absent prefs and empty prefs are equivalent (the picker shows every registry entry). """ - prefs = owner.model_prefs_json or {} - hidden = prefs.get("hidden_ids") or [] - # Defensive normalize: any persisted non-string falls out here so - # the client never sees a malformed entry. - return {"hidden_ids": [s for s in hidden if isinstance(s, str)]} + return {"hidden_ids": providers.hidden_model_ids(owner.model_prefs_json)} @owner_router.patch("/model-prefs", dependencies=[Depends(reject_cross_site)]) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index c7d4d0ddb..a4ffed14a 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -149,7 +149,7 @@ def test_providers_models_accepts_app_token(client, auth): app_id = r0.json()["id"] from app.auth import create_access_token - from app.providers import KNOWN_MODELS, invalidate_model_cache + from app.providers import DEFAULT_VISIBLE_MODELS, invalidate_model_cache invalidate_model_cache() app_token = create_access_token({ "sub": "test", "scope": "app", "app_id": app_id, @@ -160,9 +160,10 @@ def test_providers_models_accepts_app_token(client, auth): ) assert r.status_code == 200, r.text body = r.json() - # Full per-provider list, not a one-model FALLBACK_GROUPS stub. - assert [m["id"] for m in body["claude"]] == KNOWN_MODELS["claude"] - assert [m["id"] for m in body["codex"]] == KNOWN_MODELS["codex"] + # The same curated defaults the owner sees, not a one-model fallback stub. + assert {m["id"] for m in body["claude"]} == DEFAULT_VISIBLE_MODELS["claude"] + assert {m["id"] for m in body["codex"]} == DEFAULT_VISIBLE_MODELS["codex"] + assert len(body["claude"]) > 1 and len(body["codex"]) > 1 def test_providers_status_accepts_app_token(client, auth): @@ -339,22 +340,28 @@ def test_providers_models_returns_known_models_on_missing_creds( `list_models` falls back to KNOWN_MODELS — exercise that path and pin the response shape mini-apps depend on (id + name, plus a tier on Claude rows).""" - from app.providers import KNOWN_MODELS, invalidate_model_cache + from app.providers import DEFAULT_VISIBLE_MODELS, KNOWN_MODELS, invalidate_model_cache invalidate_model_cache() r = client.get("/api/auth/providers/models", headers=auth) assert r.status_code == 200 body = r.json() assert set(body) == {"claude", "codex"} claude_ids = [m["id"] for m in body["claude"]] - assert claude_ids == KNOWN_MODELS["claude"] + assert claude_ids == [ + "claude-fable-5", "claude-sonnet-5", + "claude-opus-4-8", "claude-sonnet-4-6", + ] codex_ids = [m["id"] for m in body["codex"]] - assert codex_ids == KNOWN_MODELS["codex"] + assert codex_ids == [ + "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", + ] + assert set(claude_ids) == DEFAULT_VISIBLE_MODELS["claude"] + assert set(codex_ids) == DEFAULT_VISIBLE_MODELS["codex"] # Claude rows carry a tier derived from the id. by_id = {m["id"]: m for m in body["claude"]} assert by_id["claude-opus-4-8"]["name"] == "Opus 4.8" assert by_id["claude-opus-4-8"]["tier"] == "opus" assert by_id["claude-sonnet-4-6"]["tier"] == "sonnet" - assert by_id["claude-haiku-4-5-20251001"]["tier"] == "haiku" # Codex rows intentionally omit `tier` — the field doesn't apply. for row in body["codex"]: assert "tier" not in row diff --git a/backend/tests/test_model_registry.py b/backend/tests/test_model_registry.py index 33bb720d2..657bc381f 100644 --- a/backend/tests/test_model_registry.py +++ b/backend/tests/test_model_registry.py @@ -48,6 +48,8 @@ def test_known_models_fallback_lists_current_claude_and_codex(): # asserting them by name catches a wrong date suffix that a startswith # check would miss. for model_id in ( + "claude-fable-5", + "claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", @@ -58,7 +60,13 @@ def test_known_models_fallback_lists_current_claude_and_codex(): "claude-haiku-4-5-20251001", ): assert model_id in claude, f"{model_id} missing from KNOWN_MODELS[claude]" - assert claude[0] == "claude-opus-4-8", "Opus 4.8 must be the default" + assert claude[:4] == [ + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-opus-4-7", + ] + assert providers.DEFAULT_MODELS["claude"] == "claude-opus-4-8" # Current Codex family — each canonical id present by name. for model_id in ( "gpt-5.6-sol", @@ -100,6 +108,24 @@ def test_recovery_models_match_platform_fallback_registry(): assert chat_runner.RECOVERY_MODELS == expected +def test_default_model_visibility_is_curated_until_owner_saves_preferences(): + hidden = set(providers.hidden_model_ids(None)) + for model_id in ( + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + ): + assert model_id not in hidden + assert "claude-opus-4-7" in hidden + assert "gpt-5.4" in hidden + assert providers.hidden_model_ids({"hidden_ids": []}) == [] + + def test_fallback_models_shape_matches_registry_entries(): """`_fallback_models` returns the same {id,label,provider,available} shape the live path produces, so the picker renders identically whether diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 7967de782..23b7be973 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -269,7 +269,7 @@ def test_get_settings_returns_background_agent_defaults(client, auth): assert body["agent_settings"]["model"] is None assert body["agent_settings"]["effort"] == "medium" assert body["background_agents"]["primary"]["provider"] == "codex" - assert body["background_agents"]["primary"]["model"] is None + assert body["background_agents"]["primary"]["model"] == "gpt-5.6-terra" assert body["background_agents"]["primary"]["effort"] == "medium" assert body["background_agents"]["fallback"] is None @@ -285,7 +285,7 @@ def test_background_agent_defaults_do_not_inherit_chat_model_defaults(tmp_path): background = providers.background_agent_settings(str(tmp_path), "codex") assert background["primary"] == { "provider": "codex", - "model": providers.DEFAULT_MODELS["codex"], + "model": providers.DEFAULT_BACKGROUND_MODELS["codex"], "effort": "medium", } assert background["fallback"] is None @@ -669,7 +669,7 @@ def test_background_agent_settings_drops_cross_provider_models(tmp_path): } assert background["fallback"] == { "provider": "codex", - "model": providers.DEFAULT_MODELS["codex"], + "model": providers.DEFAULT_BACKGROUND_MODELS["codex"], "effort": "medium", } @@ -821,11 +821,12 @@ def forbidden_exec(*a, **k): asyncio.run(providers._fetch_codex_models(str(tmp_path))) -def test_model_prefs_default_empty(client, auth): - """A fresh owner has no hidden models.""" +def test_model_prefs_default_is_curated(client, auth): + """A fresh owner starts with the compact recommended model set.""" + from app import providers res = client.get("/api/owner/model-prefs", headers=auth) assert res.status_code == 200 - assert res.json() == {"hidden_ids": []} + assert res.json() == {"hidden_ids": providers.hidden_model_ids(None)} def test_model_prefs_roundtrip_dedupes(client, auth, db): @@ -899,26 +900,38 @@ def test_model_prefs_clear(client, auth, db): assert owner.model_prefs_json == {"hidden_ids": []} -def test_live_model_entries_use_live_sdk_order_only(): - """A successful live fetch uses provider SDK/CLI order and does not - mix in stale fallback rows.""" +def test_live_model_entries_keep_curated_aliases_plus_live_extras(): + """The requested compatibility aliases survive a sparse live catalog.""" from app.providers import _live_model_entries merged = _live_model_entries( "claude", ["claude-future-model", "claude-opus-4-8"], ) - assert merged == [ - { - "id": "claude-future-model", "label": "claude-future-model", - "provider": "claude", "available": True, - }, - { - "id": "claude-opus-4-8", "label": "Opus 4.8", - "provider": "claude", "available": True, - }, + assert [row["id"] for row in merged] == [ + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + "claude-future-model", ] assert "claude-haiku-4-5-20251001" not in [m["id"] for m in merged] +def test_live_model_entries_float_curated_defaults_in_requested_order(): + from app import providers + + entries = providers._live_model_entries( + "claude", + ["claude-sonnet-5", "claude-future-model", "claude-fable-5", "claude-opus-4-8"], + ) + assert [entry["id"] for entry in entries] == [ + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + "claude-future-model", + ] + + def test_resolve_displayed_models_keeps_selected_even_when_hidden(): """The picker's filter MUST keep the currently-selected model visible even when it appears in hidden_ids. The codex-review spec diff --git a/frontend/src/components/ProviderModelPicker/ProviderModelPicker.jsx b/frontend/src/components/ProviderModelPicker/ProviderModelPicker.jsx index 7f3303e73..1abfa6dbe 100644 --- a/frontend/src/components/ProviderModelPicker/ProviderModelPicker.jsx +++ b/frontend/src/components/ProviderModelPicker/ProviderModelPicker.jsx @@ -16,6 +16,8 @@ * for older generations stay listed so existing chats that persisted them in * agent_settings_json keep resolving (the API treats them as aliases). */ export const CLAUDE_MODELS = [ + { value: 'claude-fable-5', label: 'Fable 5' }, + { value: 'claude-sonnet-5', label: 'Sonnet 5' }, { value: 'claude-opus-4-8', label: 'Opus 4.8' }, { value: 'claude-opus-4-7', label: 'Opus 4.7' }, { value: 'claude-opus-4-6', label: 'Opus 4.6' }, diff --git a/frontend/src/components/SettingsView/SettingsView.jsx b/frontend/src/components/SettingsView/SettingsView.jsx index 7b24cbd0a..92f403e6b 100644 --- a/frontend/src/components/SettingsView/SettingsView.jsx +++ b/frontend/src/components/SettingsView/SettingsView.jsx @@ -56,6 +56,10 @@ const FALLBACK_MODEL_ROWS = { claude: CLAUDE_MODELS.map((m) => ({ id: m.value, label: m.label, available: true })), codex: CODEX_MODELS.map((m) => ({ id: m.value, label: m.label, available: true })), } +const DEFAULT_BACKGROUND_MODELS = { + claude: 'claude-opus-4-8', + codex: 'gpt-5.6-terra', +} // POST /platform/apply is the authoritative outcome of the mutation it just // performed. Project that result into the status shape immediately so a failed @@ -92,6 +96,10 @@ function defaultModel(provider) { return FALLBACK_MODEL_ROWS[provider]?.[0]?.id || '' } +function defaultBackgroundModel(provider) { + return DEFAULT_BACKGROUND_MODELS[provider] || defaultModel(provider) +} + function isKnownProvider(provider) { return PROVIDER_CHOICES.some(p => p.id === provider) } @@ -112,7 +120,7 @@ function normalizeBackgroundAgents(backgroundAgents, defaultProvider = 'claude') if (!provider || seen.has(provider)) return rows.push({ provider, - model: choice?.model || defaultModel(provider), + model: choice?.model || defaultBackgroundModel(provider), effort: choice?.effort || defaultEffort(provider), enabled: Object.prototype.hasOwnProperty.call(choice || {}, 'enabled') ? choice.enabled !== false @@ -438,6 +446,9 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = const [manageModelsOpen, setManageModelsOpen] = useState(false) const setupFocusRefs = useRef({}) const [attentionSection, setAttentionSection] = useState('') + const configuredProvidersRef = useRef(configuredProviders) + const authProvidersAtStartRef = useRef(null) + configuredProvidersRef.current = configuredProviders const setSetupFocusRef = useCallback((section, node) => { if (node) setupFocusRefs.current[section] = node @@ -725,20 +736,60 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = // render, which combined with the row's CSS transitions made the // panel feel jittery. With the updater form, deps are empty. const toggleClaudeAuth = useCallback( - () => setExpandedAuth(prev => prev === 'claude' ? null : 'claude'), + () => setExpandedAuth(prev => { + if (prev !== 'claude') { + authProvidersAtStartRef.current = new Set(configuredProvidersRef.current) + } + return prev === 'claude' ? null : 'claude' + }), [], ) const toggleCodexAuth = useCallback( - () => setExpandedAuth(prev => prev === 'codex' ? null : 'codex'), + () => setExpandedAuth(prev => { + if (prev !== 'codex') { + authProvidersAtStartRef.current = new Set(configuredProvidersRef.current) + } + return prev === 'codex' ? null : 'codex' + }), [], ) - const onClaudeAuthDone = useCallback(() => { - setExpandedAuth(null) - }, []) - const onCodexAuthDone = useCallback(() => { + const onProviderConnected = useCallback((provider) => { + const providersBefore = authProvidersAtStartRef.current || configuredProviders + authProvidersAtStartRef.current = null + const newlyConnected = !providersBefore.has(provider) + if (newlyConnected) { + const current = backgroundDraftRef.current || normalizeBackgroundAgents( + settingsQuery.data?.background_agents, + providerFromSettings(settingsQuery.data), + ) + const connectedRow = { + ...(current.find(row => row.provider === provider) || { provider }), + enabled: true, + model: defaultBackgroundModel(provider), + effort: defaultEffort(provider), + } + const rest = current.filter(row => row.provider !== provider) + const next = providersBefore.size === 0 + ? [connectedRow, ...rest.map(row => ({ ...row, enabled: false }))] + : current.map(row => row.provider === provider ? connectedRow : row) + backgroundDraftRef.current = next + setBackgroundDraft(next) + persistBackgroundAgents(next) + if (providersBefore.size === 0) { + api.settings.save({ provider }).then(() => { + settingsQueries.owner.invalidate(queryClient) + }).catch(() => {}) + } + } settingsQueries.owner.invalidate(queryClient) setExpandedAuth(null) - }, [queryClient]) + }, [configuredProviders, persistBackgroundAgents, queryClient, settingsQuery.data]) + const onClaudeAuthDone = useCallback(() => { + onProviderConnected('claude') + }, [onProviderConnected]) + const onCodexAuthDone = useCallback(() => { + onProviderConnected('codex') + }, [onProviderConnected]) async function toggleTheme() { if (themeSwitching) return @@ -1285,7 +1336,7 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = }} onModelChange={(model, effort) => setBackgroundProviderChoice(row.provider, { enabled: !!model, - model: model || defaultModel(row.provider), + model: model || defaultBackgroundModel(row.provider), ...(effort ? { effort } : {}), })} onEffortChange={(effort) => setBackgroundProviderChoice(row.provider, { effort })} diff --git a/frontend/src/lib/__tests__/settingsViewAppearance.test.js b/frontend/src/lib/__tests__/settingsViewAppearance.test.js index 381239bcd..b3793744f 100644 --- a/frontend/src/lib/__tests__/settingsViewAppearance.test.js +++ b/frontend/src/lib/__tests__/settingsViewAppearance.test.js @@ -48,6 +48,16 @@ test('background agents are always draggable without reorder chrome or a trailin assert.doesNotMatch(css, /settings-bg-row--drop-before|settings-bg-row--drop-after/) }) +test('new provider connections use the curated unattended defaults', () => { + assert.match(view, /claude: 'claude-opus-4-8'/) + assert.match(view, /codex: 'gpt-5\.6-terra'/) + assert.match(view, /authProvidersAtStartRef\.current = new Set\(configuredProvidersRef\.current\)/) + assert.match(view, /const newlyConnected = !providersBefore\.has\(provider\)/) + assert.match(view, /providersBefore\.size === 0[\s\S]*connectedRow[\s\S]*enabled: false/) + assert.match(view, /api\.settings\.save\(\{ provider \}\)/) + assert.match(view, /effort: defaultEffort\(provider\)/) +}) + test('appearance indicator waits for the same seeded theme repaint as the palette', () => { assert.doesNotMatch(view, /setThemeMode\(newMode\)/) assert.match(view, /await themeService\.toggleTheme\(queryClient, currentMode, api\)/) From 84cbfae88ff925c19f2735a30a145165dde8018d Mon Sep 17 00:00:00 2001 From: hamzamerzic <10846014+hamzamerzic@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:29:42 +0000 Subject: [PATCH 2/4] Make first provider defaults one durable transition --- backend/app/routes/settings.py | 5 +-- backend/tests/test_settings.py | 22 ++++++++++++ .../components/SettingsView/SettingsView.jsx | 36 ++++++++++++------- .../__tests__/settingsViewAppearance.test.js | 6 +++- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/backend/app/routes/settings.py b/backend/app/routes/settings.py index 1c876ddc8..997cb3406 100644 --- a/backend/app/routes/settings.py +++ b/backend/app/routes/settings.py @@ -373,8 +373,9 @@ def get_model_prefs( ) -> dict: """Returns the owner's model-picker preferences. - Default shape is `{"hidden_ids": []}` — absent prefs and empty - prefs are equivalent (the picker shows every registry entry). + Owners without a saved preference receive the curated default hidden set. + An explicitly saved `{"hidden_ids": []}` is distinct and shows every + registry entry. """ return {"hidden_ids": providers.hidden_model_ids(owner.model_prefs_json)} diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 23b7be973..bdab40055 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -829,6 +829,28 @@ def test_model_prefs_default_is_curated(client, auth): assert res.json() == {"hidden_ids": providers.hidden_model_ids(None)} +def test_model_prefs_explicit_empty_is_distinct_from_missing(client, auth, db): + """Saving an empty hidden list opts into showing the whole registry.""" + from app import models, providers + + owner = db.query(models.Owner).first() + assert owner.model_prefs_json is None + assert providers.hidden_model_ids(owner.model_prefs_json) + + res = client.patch( + "/api/owner/model-prefs", + json={"hidden_ids": []}, + headers=auth, + ) + assert res.status_code == 200 + assert res.json() == {"hidden_ids": []} + db.refresh(owner) + assert owner.model_prefs_json == {"hidden_ids": []} + assert client.get("/api/owner/model-prefs", headers=auth).json() == { + "hidden_ids": [], + } + + def test_model_prefs_roundtrip_dedupes(client, auth, db): """PATCH stores hidden_ids verbatim (deduplicated, order-preserving) and GET returns the same set.""" diff --git a/frontend/src/components/SettingsView/SettingsView.jsx b/frontend/src/components/SettingsView/SettingsView.jsx index 92f403e6b..7bc039e0b 100644 --- a/frontend/src/components/SettingsView/SettingsView.jsx +++ b/frontend/src/components/SettingsView/SettingsView.jsx @@ -502,14 +502,15 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = return FALLBACK_MODEL_ROWS[provider] || [] }, [modelRegistryQuery.data]) - const persistBackgroundAgents = useCallback((draft) => { + const persistBackgroundAgents = useCallback((draft, companionSettings = {}) => { const rows = Array.isArray(draft) ? draft : [] const enabled = rows.filter(row => row.enabled !== false) if (!enabled.length) { setBackgroundError('Choose at least one background model.') - return Promise.resolve() + return Promise.resolve(false) } const reqId = ++backgroundSaveReqRef.current + const isCompanionSave = Object.keys(companionSettings).length > 0 setBackgroundError('') const save = backgroundSaveChainRef.current.catch(() => {}).then(async () => { try { @@ -528,18 +529,26 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = primary: toChoice(enabled[0]), fallback: enabled[1] ? toChoice(enabled[1]) : null, } - const res = await api.settings.save({ background_agents: payload }) - if (reqId !== backgroundSaveReqRef.current) return + // A first provider connection also establishes the interactive default. + // Keep that transition in one settings write so disk failure cannot + // persist one half while the UI reports the whole setup as complete. + const res = await api.settings.save({ + ...companionSettings, + background_agents: payload, + }) + if (reqId !== backgroundSaveReqRef.current) return true if (!res.ok) { let detail = '' try { detail = (await res.json()).detail || '' } catch {} throw new Error(detail || 'Could not save background agents.') } settingsQueries.owner.invalidate(queryClient) + return true } catch (err) { - if (reqId === backgroundSaveReqRef.current) { + if (reqId === backgroundSaveReqRef.current || isCompanionSave) { setBackgroundError(err.message || 'Could not save background agents.') } + return false } }) backgroundSaveChainRef.current = save @@ -753,9 +762,8 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = }), [], ) - const onProviderConnected = useCallback((provider) => { + const onProviderConnected = useCallback(async (provider) => { const providersBefore = authProvidersAtStartRef.current || configuredProviders - authProvidersAtStartRef.current = null const newlyConnected = !providersBefore.has(provider) if (newlyConnected) { const current = backgroundDraftRef.current || normalizeBackgroundAgents( @@ -774,13 +782,15 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = : current.map(row => row.provider === provider ? connectedRow : row) backgroundDraftRef.current = next setBackgroundDraft(next) - persistBackgroundAgents(next) - if (providersBefore.size === 0) { - api.settings.save({ provider }).then(() => { - settingsQueries.owner.invalidate(queryClient) - }).catch(() => {}) - } + const saved = await persistBackgroundAgents( + next, + providersBefore.size === 0 ? { provider } : {}, + ) + // Authentication itself succeeded, but keep the panel and visible error + // in place until its associated defaults are durably saved. + if (!saved) return } + authProvidersAtStartRef.current = null settingsQueries.owner.invalidate(queryClient) setExpandedAuth(null) }, [configuredProviders, persistBackgroundAgents, queryClient, settingsQuery.data]) diff --git a/frontend/src/lib/__tests__/settingsViewAppearance.test.js b/frontend/src/lib/__tests__/settingsViewAppearance.test.js index b3793744f..e5efdc426 100644 --- a/frontend/src/lib/__tests__/settingsViewAppearance.test.js +++ b/frontend/src/lib/__tests__/settingsViewAppearance.test.js @@ -54,7 +54,11 @@ test('new provider connections use the curated unattended defaults', () => { assert.match(view, /authProvidersAtStartRef\.current = new Set\(configuredProvidersRef\.current\)/) assert.match(view, /const newlyConnected = !providersBefore\.has\(provider\)/) assert.match(view, /providersBefore\.size === 0[\s\S]*connectedRow[\s\S]*enabled: false/) - assert.match(view, /api\.settings\.save\(\{ provider \}\)/) + assert.match(view, /const onProviderConnected = useCallback\(async \(provider\)/) + assert.match(view, /await persistBackgroundAgents\([\s\S]*providersBefore\.size === 0 \? \{ provider \} : \{\}/) + assert.match(view, /api\.settings\.save\(\{[\s\S]*\.\.\.companionSettings,[\s\S]*background_agents: payload/) + assert.match(view, /if \(!saved\) return[\s\S]*setExpandedAuth\(null\)/) + assert.doesNotMatch(view, /api\.settings\.save\(\{ provider \}\)/) assert.match(view, /effort: defaultEffort\(provider\)/) }) From 30c03d0df09e1651f8f291e8959a3341eb34bc8f Mon Sep 17 00:00:00 2001 From: hamzamerzic <10846014+hamzamerzic@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:57:09 +0000 Subject: [PATCH 3/4] Keep Recovery Claude snapshot current --- backend/recovery/recovery_chat_runner.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/recovery/recovery_chat_runner.py b/backend/recovery/recovery_chat_runner.py index c6d2aa9a3..e15e60f4a 100644 --- a/backend/recovery/recovery_chat_runner.py +++ b/backend/recovery/recovery_chat_runner.py @@ -104,6 +104,8 @@ # picker always allows "CLI default" (no --model) too. RECOVERY_MODELS: dict[str, tuple[str, ...]] = { "claude": ( + "claude-fable-5", + "claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", From 98ff6428a93a0381b5a15934068d3f41df77703f Mon Sep 17 00:00:00 2001 From: Hamza Merzic Date: Wed, 22 Jul 2026 18:34:08 +0000 Subject: [PATCH 4/4] Preserve stale background save failures --- .../components/SettingsView/SettingsView.jsx | 12 +++---- .../lib/__tests__/backgroundAgentSave.test.js | 35 +++++++++++++++++++ .../__tests__/settingsViewAppearance.test.js | 1 + frontend/src/lib/backgroundAgentSave.js | 12 +++++++ 4 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/__tests__/backgroundAgentSave.test.js create mode 100644 frontend/src/lib/backgroundAgentSave.js diff --git a/frontend/src/components/SettingsView/SettingsView.jsx b/frontend/src/components/SettingsView/SettingsView.jsx index 7bc039e0b..5393665e9 100644 --- a/frontend/src/components/SettingsView/SettingsView.jsx +++ b/frontend/src/components/SettingsView/SettingsView.jsx @@ -7,6 +7,7 @@ import Sun from 'lucide-react/dist/esm/icons/sun.mjs' import { api, clearQueryCache, clearToken } from '../../api/client.js' import { authQueries, modelQueries, settingsQueries, themeQueries, versionQueries } from '../../hooks/queries.js' import { platformVersionIdentity } from '../../lib/platformVersionIdentity.js' +import { settleBackgroundAgentSave } from '../../lib/backgroundAgentSave.js' import { PROVIDER_AVAILABILITY_PHASE, resolveProviderAvailability, @@ -536,12 +537,11 @@ export default function SettingsView({ onThemeChange, onOpenChat, focusTarget = ...companionSettings, background_agents: payload, }) - if (reqId !== backgroundSaveReqRef.current) return true - if (!res.ok) { - let detail = '' - try { detail = (await res.json()).detail || '' } catch {} - throw new Error(detail || 'Could not save background agents.') - } + const { stale } = await settleBackgroundAgentSave( + res, + () => reqId !== backgroundSaveReqRef.current, + ) + if (stale) return true settingsQueries.owner.invalidate(queryClient) return true } catch (err) { diff --git a/frontend/src/lib/__tests__/backgroundAgentSave.test.js b/frontend/src/lib/__tests__/backgroundAgentSave.test.js new file mode 100644 index 000000000..298c04239 --- /dev/null +++ b/frontend/src/lib/__tests__/backgroundAgentSave.test.js @@ -0,0 +1,35 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { settleBackgroundAgentSave } from '../backgroundAgentSave.js' + +test('a stale request still reports its HTTP failure before freshness', async () => { + let freshnessChecks = 0 + const response = { + ok: false, + async json() { return { detail: 'Companion settings were not saved.' } }, + } + + await assert.rejects( + settleBackgroundAgentSave(response, () => { + freshnessChecks += 1 + return true + }), + /Companion settings were not saved/, + ) + assert.equal(freshnessChecks, 0) +}) + +test('a successful superseded request may settle as stale', async () => { + let freshnessChecks = 0 + const result = await settleBackgroundAgentSave( + { ok: true }, + () => { + freshnessChecks += 1 + return true + }, + ) + + assert.deepEqual(result, { stale: true }) + assert.equal(freshnessChecks, 1) +}) diff --git a/frontend/src/lib/__tests__/settingsViewAppearance.test.js b/frontend/src/lib/__tests__/settingsViewAppearance.test.js index e5efdc426..654281e50 100644 --- a/frontend/src/lib/__tests__/settingsViewAppearance.test.js +++ b/frontend/src/lib/__tests__/settingsViewAppearance.test.js @@ -57,6 +57,7 @@ test('new provider connections use the curated unattended defaults', () => { assert.match(view, /const onProviderConnected = useCallback\(async \(provider\)/) assert.match(view, /await persistBackgroundAgents\([\s\S]*providersBefore\.size === 0 \? \{ provider \} : \{\}/) assert.match(view, /api\.settings\.save\(\{[\s\S]*\.\.\.companionSettings,[\s\S]*background_agents: payload/) + assert.match(view, /await settleBackgroundAgentSave\([\s\S]*if \(stale\) return true/) assert.match(view, /if \(!saved\) return[\s\S]*setExpandedAuth\(null\)/) assert.doesNotMatch(view, /api\.settings\.save\(\{ provider \}\)/) assert.match(view, /effort: defaultEffort\(provider\)/) diff --git a/frontend/src/lib/backgroundAgentSave.js b/frontend/src/lib/backgroundAgentSave.js new file mode 100644 index 000000000..f11c7e653 --- /dev/null +++ b/frontend/src/lib/backgroundAgentSave.js @@ -0,0 +1,12 @@ +// Interpret the settings response before consulting request freshness. A save +// can become stale while it waits behind a later edit, but an HTTP failure is +// still a real failure -- especially when the save also carries the provider +// selected by a just-completed authentication flow. +export async function settleBackgroundAgentSave(response, isStale) { + if (!response?.ok) { + let detail = '' + try { detail = (await response?.json?.())?.detail || '' } catch {} + throw new Error(detail || 'Could not save background agents.') + } + return { stale: Boolean(isStale?.()) } +}