Skip to content

fix: stabilize model toggle and refresh Agent dropdown after provider changes#13113

Merged
erichare merged 5 commits into
release-1.9.3from
fix/model-provider-toggle-bounce-and-agent-cache
May 13, 2026
Merged

fix: stabilize model toggle and refresh Agent dropdown after provider changes#13113
erichare merged 5 commits into
release-1.9.3from
fix/model-provider-toggle-bounce-and-agent-cache

Conversation

@erichare
Copy link
Copy Markdown
Collaborator

@erichare erichare commented May 13, 2026

Summary

Two related regressions on release-1.9.3 in the Model Provider management flow:

  1. Toggle bounce — Rapid clicks on a model Switch in the Google Generative AI (and other provider) sections flickered between on/off before settling.
  2. Agent dropdown stayed stale — After disabling Gemini models in the global provider settings and closing the modal, the Agent component's Language Model dropdown still listed the disabled models.

Both have a common shape: optimistic/cached state being overwritten or read while still stale.

Root cause

Toggle bounce

handleModelToggle performed an optimistic queryClient.setQueryData on ["useGetEnabledModels"] but did not call cancelQueries first. With the project's default QueryClient (created with no defaultOptions in contexts/index.tsx), React Query falls back to staleTime: 0, refetchOnWindowFocus: true, refetchOnMount: true. The flush mutation is debounced 1s. During that window any background refetch would land with stale server data and overwrite the optimistic state, snapping the Switch back to its prior value — the visible bounce. When the deferred mutation finally landed, onSettled invalidated again and the cache returned to the correct state.

Agent dropdown stale

When the Manage Model Providers modal closes, the consuming ModelInputComponent sets isRefreshingAfterClose=true and waits for useGetModelProviders.isFetching to cycle before clearing the loading button. It did not observe useGetEnabledModels.isFetching. Both queries are invalidated together by refreshAllModelInputs, but they refetch concurrently — if providers settles first, the loading state clears and groupedOptions filters against still-stale enabledModelsData, leaking the just-disabled models back into the dropdown until the next interaction.

Fix

The sticky-default UX is preserved — the user's previously-selected model continues to show in the dropdown with the wrench/Configure affordance even when globally disabled (by design via the not_enabled_locally tag).

Also narrowed the pre-existing catch (error: any) patterns in the edited hook to unknown via a tiny shared getErrorMessage helper, to satisfy the staged-file no-explicit-any pre-commit lint that fires whenever the file is touched.

Test plan

  • npx jest src/modals/modelProviderModal src/components/core/parameterRenderComponent/components/modelInputComponent — 87/87 pass
  • New useProviderConfiguration.test.tsx: asserts cancelQueries is invoked before setQueryData on toggle
  • Extended ModelInputComponent.test.tsx: simulates providers settling first while enabled-models is still in flight — loading button persists until both settle
  • Manual: configure Google Generative AI, rapidly toggle models in the modal — no bounce; disable all Gemini, close modal, open an Agent's Language Model dropdown — no stale Gemini entries (other than the sticky-default for a previously-selected model)

Summary by CodeRabbit

Bug Fixes

  • Fixed loading indicator to accurately persist until all model provider and model data have completed refreshing after closing the model provider modal
  • Improved error message handling to display clearer, user-friendly feedback when issues occur
  • Prevented transient dropdown option staleness when model data refreshes following modal closure

Review Change Stack

… changes

Two related regressions in the Model Provider management flow on
release-1.9.3:

1. Toggle bounce — rapid clicks on a model Switch flickered between on
   and off before settling. ``handleModelToggle`` performed an
   optimistic ``setQueryData`` on ``useGetEnabledModels`` but never
   cancelled in-flight refetches. With React Query defaults
   (``staleTime: 0``, ``refetchOnWindowFocus: true``), a background
   refetch could land mid-debounce and overwrite the optimistic state
   with stale server data, then the deferred mutation eventually
   corrected it. Fix: ``cancelQueries`` before each optimistic update,
   per the canonical TanStack Query optimistic-updates pattern.

2. Agent dropdown stayed stale after the provider modal closed —
   disabled models remained listed in the Agent's Language Model
   dropdown. The post-close ``isRefreshingAfterClose`` loading gate
   only waited for ``useGetModelProviders`` to settle, not
   ``useGetEnabledModels``. When the providers refetch finished first,
   the loading state cleared and ``groupedOptions`` ran against the
   still-stale enabled-models cache. Fix: track both queries'
   ``isFetching`` flags in the gate's effect.

No behavioral change to the sticky-default UX — the user's previously
selected model continues to show in the dropdown with the wrench
affordance when globally disabled, as designed.

Also narrows the pre-existing ``catch (error: any)`` patterns in the
edited hook file to ``unknown`` with a small shared ``getErrorMessage``
helper, satisfying the staged-file no-explicit-any pre-commit lint.

Tests:
- New ``useProviderConfiguration`` unit test asserts
  ``cancelQueries`` runs before ``setQueryData`` on toggle.
- Extended ``ModelInputComponent`` test covers the dual-query loading
  gate (providers settles first, enabled-models still in flight →
  loading persists; then enabled-models settles → loading clears).
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a8179919-d23f-4fa8-abcc-7c52054f0dbe

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This PR coordinates model loading state across two query lifecycles and prevents race conditions during model toggles. The hook cancels in-flight enabled-models queries at the start of handleModelToggle, while the component now tracks both provider and enabled-models fetching states to keep the loading indicator active until both queries finish refetching. A new error extraction helper normalizes error handling across all hook error paths.

Changes

Model Refetch Coordination and Toggle Safety

Layer / File(s) Summary
Query cancellation and error extraction
src/frontend/src/modals/modelProviderModal/hooks/useProviderConfiguration.ts
Added getErrorMessage(error: unknown) helper to extract user-facing messages from Axios and standard Error objects. Updated handleModelToggle to cancel in-flight useGetEnabledModels queries via queryClient.cancelQueries to prevent refetch results from overwriting optimistic cache updates during rapid toggles.
Component dual-query completion tracking
src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx, src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx
ModelInputComponent now destructures isFetchingEnabledModels from the hook and waits for both isFetchingProviders and isFetchingEnabledModels to complete before clearing the refreshing indicator. Test helper renderWithQueryClient now preserves QueryClientProvider context across rerenders via rerenderWithProvider wrapper.
Error handling normalization across hook operations
src/frontend/src/modals/modelProviderModal/hooks/useProviderConfiguration.ts
Applied getErrorMessage helper consistently across validateCredentials, handleSaveAllVariables, handleActivateProvider, handleDisconnect, and both mutation error paths. Each now catches unknown and derives error messages uniformly with fallback text.
Test coverage for cancellation and dual-query coordination
src/frontend/src/modals/modelProviderModal/__tests__/useProviderConfiguration.test.tsx, src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx
New test for useProviderConfiguration with instrumented React Query client mock verifying cancelQueries precedes setQueryData during model toggle. Extended ModelInputComponent tests with async case confirming loading persists until both provider and enabled-models refetches complete.

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 9
✅ Passed checks (9 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: fixing model toggle stability and Agent dropdown refresh after provider changes, which directly align with the two key regressions addressed in the PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test Coverage For New Implementations ✅ Passed Tests added for bug fixes. Regression tests verify cancelQueries/setQueryData ordering and dual-query loading gate. Tests follow .test.tsx convention with meaningful assertions and edge cases.
Test Quality And Coverage ✅ Passed Tests cover core functionality. ModelInputComponent: 24 tests validate dual-query behavior. useProviderConfiguration: Tests verify cancelQueries order. Async patterns used. Tests behavioral not smoke.
Test File Naming And Structure ✅ Passed Both test files follow *.test.tsx naming pattern, use Jest/React Testing Library, have descriptive test names, proper organization, setup/teardown, edge cases, and positive/negative scenario coverage.
Excessive Mock Usage Warning ✅ Passed All 20 mocks across both test files target external dependencies (API hooks, stores, React Query), not core logic. Tests verify critical regression fixes with clear assertions about actual behavior.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/model-provider-toggle-bounce-and-agent-cache

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels May 13, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

❌ Patch coverage is 96.04222% with 15 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (release-1.9.3@00bc383). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...delProviderModal/hooks/useProviderConfiguration.ts 46.42% 15 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@               Coverage Diff                @@
##             release-1.9.3   #13113   +/-   ##
================================================
  Coverage                 ?   53.26%           
================================================
  Files                    ?     2033           
  Lines                    ?   184872           
  Branches                 ?    27404           
================================================
  Hits                     ?    98471           
  Misses                   ?    85291           
  Partials                 ?     1110           
Flag Coverage Δ
backend 56.24% <ø> (?)
frontend 53.32% <96.04%> (?)
lfx 50.09% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...Component/components/modelInputComponent/index.tsx 92.81% <100.00%> (ø)
...ls/modelProviderModal/hooks/useModelToggleQueue.ts 100.00% <100.00%> (ø)
...delProviderModal/hooks/useProviderConfiguration.ts 28.11% <46.42%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@erichare erichare requested a review from vjgit96 May 13, 2026 17:45
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx (1)

395-398: ⚡ Quick win

Avoid fixed sleeps in async UI tests.

Line 397 uses a hardcoded timeout, which can make this test flaky in slower CI runs. Prefer assertion-driven waiting.

Suggested change
-      await new Promise((r) => setTimeout(r, 30));
-      expect(screen.getByText("Loading models")).toBeInTheDocument();
+      await waitFor(() => {
+        expect(screen.getByText("Loading models")).toBeInTheDocument();
+      });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx`
around lines 395 - 398, The test uses a fixed sleep to wait for async UI state;
replace the hardcoded await new Promise((r) => setTimeout(r, 30)) with an
assertion-driven wait (e.g., use waitFor or findBy* from testing-library) so the
test waits until the "Loading models" text appears after you call
rerenderWithProvider(<ModelInputComponent {...defaultProps} />) with
providersFetching = false; target the assertion using screen.findByText or await
waitFor(() => expect(screen.getByText("Loading models")).toBeInTheDocument()) to
avoid flakiness.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx`:
- Around line 395-398: The test uses a fixed sleep to wait for async UI state;
replace the hardcoded await new Promise((r) => setTimeout(r, 30)) with an
assertion-driven wait (e.g., use waitFor or findBy* from testing-library) so the
test waits until the "Loading models" text appears after you call
rerenderWithProvider(<ModelInputComponent {...defaultProps} />) with
providersFetching = false; target the assertion using screen.findByText or await
waitFor(() => expect(screen.getByText("Loading models")).toBeInTheDocument()) to
avoid flakiness.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0450bdd4-991f-41e7-9937-154c9eba6a7f

📥 Commits

Reviewing files that changed from the base of the PR and between 00bc383 and 463c097.

📒 Files selected for processing (4)
  • src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx
  • src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx
  • src/frontend/src/modals/modelProviderModal/__tests__/useProviderConfiguration.test.tsx
  • src/frontend/src/modals/modelProviderModal/hooks/useProviderConfiguration.ts

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 13, 2026

Frontend Unit Test Coverage Report

Coverage Summary

Lines Statements Branches Functions
Coverage: 35%
35.47% (40695/114715) 68.12% (5605/8227) 35.95% (943/2623)

Unit Test Results

Tests Skipped Failures Errors Time
4018 0 💤 0 ❌ 0 🔥 8m 45s ⏱️

Addresses review feedback on #13113: ``cancelQueries`` only cancels
refetches that are already in flight at click time. A new
``useGetEnabledModels`` refetch can still start during the 1s
debounce window (or while the mutation is in flight) and overwrite
the optimistic cache with stale server state, producing the same
bounce we just fixed.

Two coordinated changes keep the optimistic state protected for the
entire pending-toggle window:

1. ``pendingModelToggles`` is no longer cleared upfront when the
   debounced flush sends a mutation. Instead, ``clearSentToggles``
   removes only the entries whose value still matches what we sent
   on ``onSettled`` / ``onError``. This preserves the overlay across
   the in-flight mutation window AND correctly handles the case
   where the user re-toggled the same model mid-flight (now a fresh
   intent that survives clearing).

2. A new ``useEffect`` subscribes to ``useGetEnabledModels`` and
   re-applies the pending overlay whenever the data emission drifts
   from the user's pending intent. This catches any refetch
   (window focus, mount, reconnect, stale-time expiry) that lands
   between click and ``onSettled``, regardless of when it started.

Tests:
- New ``re-applies the pending overlay when a refetch surfaces
  stale data`` test simulates a stale refetch and asserts the
  effect re-applies the optimistic overlay.
- New ``does not re-overlay when no toggles are pending`` test
  guards against spurious overlay calls on mount.
@erichare
Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback in 0ba3881:

The reviewer correctly pointed out that cancelQueries only stops in-flight refetches at click time — a new refetch can still start during the 1s debounce window (or while the mutation is on the wire) and clobber the optimistic cache. Two coordinated changes now keep the optimistic state protected across the entire pending-toggle window:

  1. pendingModelToggles is no longer cleared upfront when the debounced flush sends a mutation. A new clearSentToggles helper removes only the entries whose value still matches what we sent, on onSettled/onError. This (a) preserves the overlay across the in-flight mutation window and (b) correctly handles a user re-toggling the same model mid-flight (now a fresh intent that survives the clear).
  2. A new useEffect re-applies the pending overlay whenever useGetEnabledModels emits new data that drifts from the user's pending intent. This catches any refetch (window focus, mount, reconnect, stale-time expiry) that lands between the click and onSettled, regardless of when it started.

Two new unit tests:

  • re-applies the pending overlay when a refetch surfaces stale data — simulates a stale refetch via the mocked useGetEnabledModels and asserts the effect re-applies the optimistic overlay.
  • does not re-overlay when no toggles are pending — guards against spurious overlay calls on mount.

All 89 tests pass locally; biome and the staged-file no-any pre-commit pass.

@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels May 13, 2026
…e mutations

Addresses a follow-up race introduced by the previous commit: keeping
``pendingModelToggles`` populated through ``onSettled`` (so the
re-overlay effect could repel mid-flight refetches) also caused the
next flush to snapshot the in-flight entries again, sending duplicate
requests with non-deterministic success/failure ordering. The same
risk applied to ``flushPendingChanges`` on modal close.

Split the single buffer into two refs with single responsibilities:

  - ``overlayToggles``: the union of every toggle still protecting the
    UI. The re-overlay effect re-applies this whenever
    ``useGetEnabledModels`` emits new data. Drained per-key on
    ``onSettled``/``onError`` only when the overlay value still matches
    what we sent (a user re-toggle mid-flight becomes a fresh intent
    and must survive the clear).

  - ``unsentToggles``: the strict subset that has NOT been sent in a
    mutation yet, or was re-toggled since the last send. Drained
    immediately at flush time so subsequent flushes never resend an
    in-flight payload.

``handleModelToggle`` populates both buffers; flushes drain only
``unsentToggles`` and use ``overlayToggles`` for cache protection.

Two new unit tests:
- ``does not resend in-flight toggles when a new toggle is flushed``
  asserts that after toggling A then B (with A's mutation in flight),
  the second flush carries ONLY B.
- ``re-sends a model when the user re-toggles it after the previous
  flush fired`` asserts that re-toggling the same model produces a
  second mutation (the re-toggle is a fresh intent, not a duplicate).
@erichare
Copy link
Copy Markdown
Collaborator Author

Addressed the follow-up race in 4c8d782.

The previous commit's choice to keep pendingModelToggles populated through onSettled (so the re-overlay effect could repel mid-flight refetches) also caused the next flush to snapshot those in-flight entries again, producing duplicate mutations. Split into two refs with single responsibilities as suggested:

  • overlayToggles — every toggle still protecting the UI. Drained per-key on onSettled/onError only when the overlay value still matches what we sent (so a user re-toggle mid-flight survives the clear).
  • unsentToggles — strict subset that has NOT been sent yet, or was re-toggled since the last send. Drained immediately at flush time so subsequent flushes never resend an in-flight payload.

handleModelToggle populates both; flushes drain only unsentToggles and use overlayToggles for cache protection. Same treatment applied to flushPendingChanges on modal close.

Two new unit tests directly cover the duplicate-send concern:

  • does not resend in-flight toggles when a new toggle is flushed — toggling A then B (with A in flight) makes the second flush carry ONLY B.
  • re-sends a model when the user re-toggles it after the previous flush fired — re-toggling A produces a second mutation (fresh intent, not a duplicate).

91/91 tests pass; biome + staged-file no-any pre-commit pass.

@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels May 13, 2026
…ther

Removes the sticky-default carve-out so a globally-disabled model is
never shown in the Agent's Language Model dropdown, regardless of
whether it's the node's saved selection. Drops the Wrench/Configure
affordance — there's no per-trigger "this model isn't enabled for
your user" UX anymore; the dropdown is the single source of truth.

Changes in ``modelInputComponent``:

  - ``groupedOptions``: drop the ``isStickyNotEnabled`` filter
    bypass and the zero-provider import fallback. Disabled models
    never pass the filter, even when tagged ``not_enabled_locally``.

  - ``selectedModel``: drop the saved-value preservation branch.
    If the saved name isn't in ``flatOptions``, fall through to the
    first available option (or ``null`` if nothing is available).

  - Auto-select effect: fires whenever the saved value isn't in
    ``flatOptions``, not just when the value is empty. This
    realigns the node's stored value with the trigger so the
    rendered selection and the run-time selection don't diverge.

  - ``hasEnabledProviders``: redefined as "at least one model of
    this component's type is enabled across any provider"
    (derived from ``groupedOptions``). Routes a configured-but-
    all-disabled provider to the Setup Provider CTA — same UX as
    the never-configured case.

  - Remove the Wrench JSX, the ``showConfigureAffordance``
    derivation, and the now-orphaned ``hasProcessedEmptyRef``.

Tests:
  - ``renders the Setup Provider CTA when no models are enabled``
    (replaces the old disabled-combobox expectation).
  - ``auto-selects an available model when the saved value is
    globally disabled``.
  - ``renders the Setup Provider CTA when a provider is configured
    but all its models are disabled``.
  - ``never renders the Configure wrench affordance``.
@erichare
Copy link
Copy Markdown
Collaborator Author

Per discussion: dropped the sticky-default UX entirely in a154693.

  • A globally-disabled model never appears in the Agent's dropdown, regardless of whether it's the node's saved selection. No special case for "what's currently selected".
  • The Wrench/Configure affordance is gone — no per-trigger fallback UX for a disabled-but-saved model.
  • If the saved value points at a now-disabled model and other models are still enabled, the component auto-selects the first available option and updates the stored value (so the rendered trigger and the run-time value can't diverge).
  • If a provider is configured but every model is disabled, the trigger now shows the Setup Provider CTA — same UX as the never-configured case.

Test changes:

  • renders the Setup Provider CTA when no models are enabled (replaces should render disabled combobox when no options are provided).
  • auto-selects an available model when the saved value is globally disabled.
  • renders the Setup Provider CTA when a provider is configured but all its models are disabled.
  • never renders the Configure wrench affordance.
  • Deleted the two sticky-default + wrench tests that asserted the removed behavior.

91/91 tests still pass; biome + staged-file no-any pre-commit pass.

@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels May 13, 2026
Copy link
Copy Markdown
Member

@Cristhianzl Cristhianzl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This PR fixes two related regressions in the Model Provider management flow on release-1.9.3 — a toggle "bounce" caused by an optimistic update without cancelQueries, and a stale Agent dropdown caused by a loading gate that watched only one of two refetched queries. Both fixes apply the canonical TanStack Query optimistic-update pattern, are well-commented with Why: rationale on every non-obvious block, and ship with focused adversarial unit tests (87 tests pass per the PR description). The split-buffer (overlayToggles / unsentToggles) design is correctly motivated by the duplicate-payload bug that a single buffer would create, and the re-overlay effect's loop-prevention invariant is defensible. The only structural concern is that useProviderConfiguration.ts already exceeded the 500-line hard limit before this PR, and this PR pushes it from ~681 to 821 lines — the toggle-queue logic should be extracted to a dedicated module as a follow-up.

Verdict: Approve with comments
Findings: 0 blockers, 1 important, 4 recommended


⚠️ Important (preferably this PR, otherwise tracked as follow-up)

I1 — useProviderConfiguration.ts is now 821 lines, exceeds the 500-line hard limit

File: src/frontend/src/modals/modelProviderModal/hooks/useProviderConfiguration.ts:1-821

Issue: The reviewer rule sets a hard limit of 500 lines per file, with up to 600–700 acceptable only if all other structural rules pass. The file is now 821 lines after this PR (~140 lines added net), well past even the flex cap. The hook also mixes multiple responsibilities — secret-variable CRUD, provider activation, provider disconnect, model toggle queue, debounced flush, async flush, optimistic cache management, re-overlay effect — which is a validate* AND format*-style mixed-responsibility violation extended to several categories.

Why it matters: Reviewer rule explicitly states "A 650-line file that passes all SRP and separation checks is acceptable. A 400-line file with mixed responsibilities is NOT." This file is over 800 lines AND has mixed responsibilities, so both gates are failing. The model-toggle queue (overlay buffer, unsent buffer, clearSentOverlay, flushModelToggles, flushPendingChanges, handleModelToggle, re-overlay effect) is the obvious extraction candidate — it's a self-contained unit with one responsibility and zero coupling to the variable-CRUD code above it.

Suggested fix: Extract into a dedicated hook in this PR if reviewer agrees; otherwise track as a follow-up cleanup ticket before the next change touches this file. The extracted shape would be:

modelProviderModal/hooks/
├── useProviderConfiguration.ts      (variables / save / activate / disconnect)
└── useModelToggleQueue.ts           (overlay + unsent buffers, flush, re-overlay)

Note: the existing legacy-code exemption in the rule allows up to CC 15 only if the change does NOT increase complexity. The PR does increase total file size and adds new functions, so the legacy exemption does not fully apply here.

Line-count evidence
$ wc -l src/frontend/src/modals/modelProviderModal/hooks/useProviderConfiguration.ts
821 useProviderConfiguration.ts

Pre-PR file was 681 lines (54 deletions + 681 = 735 pre-fix accounting roughly; the new code is +140 net).


💡 Recommended (can ship as a follow-up)

R1 — Duplicate "flush" logic between flushModelToggles and flushPendingChanges

File: src/frontend/src/modals/modelProviderModal/hooks/useProviderConfiguration.ts:608-700

Issue: Both functions share the same skeleton:

  1. Guard on syncedSelectedProvider?.provider.
  2. Snapshot unsentToggles.current into togglesToSend.
  3. Bail when empty.
  4. Build updates array.
  5. Capture previousData from fallbackModelData.current.
  6. Drain unsentToggles.
  7. Call mutation (sync vs async variant).
  8. On error: clearSentOverlay, restore previousData, surface error toast.

The two variants exist for legitimate reasons (debounced vs awaitable), but the shared core is now copied twice — every future change must be made in two places. The PR makes this slightly worse by adding the new clearSentOverlay call site to both.

Why it matters: DRY violation on a security-relevant path (incorrect rollback would leak a forged-looking optimistic state). The cost of a code drift here is "the two paths fall out of sync and one of them retries forever / fails to rollback / fails to drain the overlay."

Suggested fix: Extract a buildAndConsumeToggleBatch() helper that returns { updates, previousData, providerName } | null and a rollbackToggleBatch(togglesToSend, previousData) helper for the error path. Both flush variants call those, then differ only in mutate(...) vs await mutateAsync(...). This eliminates the most error-prone copy/paste.


R2 — Re-overlay effect's loop-prevention invariant deserves an explicit Why: line

File: src/frontend/src/modals/modelProviderModal/hooks/useProviderConfiguration.ts:755-784

Issue: The effect calls queryClient.setQueryData(...), which causes useGetEnabledModels to emit new data, which re-runs the effect with the just-updated enabledModelsData. The infinite-loop guard is the if (!drifted) return; short-circuit on the next pass — once the overlay has been applied, current[model] === enabled for every overlay entry, drift is false, the effect no-ops. The existing comment block describes the purpose well but does not explicitly call out the loop-prevention role of the drifted check.

Why it matters: A future refactor that "simplifies" the drift check (e.g., always re-apply on data change) would silently introduce an infinite re-render loop. The invariant should be load-bearing in the comment, not implicit.

Suggested fix: Add one line above the drifted computation:

// Loop guard: the setQueryData below re-emits enabledModelsData and re-runs
// this effect; the drift check must return false on the second pass for the
// recursion to terminate. Don't replace this with an unconditional re-apply.
const drifted = Object.entries(overlay).some(...);

R3 — Test gap: no adversarial coverage for the mutation error path

File: src/frontend/src/modals/modelProviderModal/__tests__/useProviderConfiguration.test.tsx:1-329

Issue: The new test file covers six positive-direction scenarios (cancel-before-set, no-op on null provider, re-overlay on stale refetch, no resend in-flight, re-toggle resend, no-op on mount). Per REVIEWER_RULE.md → TESTING → "Tests MUST Also Challenge the Code", both happy path AND adversarial tests must exist. The error path (mutation fails → rollback to previousData → overlay drained → re-overlay effect sees no drift) is exactly the subtle path most likely to regress, and it is not exercised.

Why it matters: The clearSentOverlay drain-before-rollback ordering in the onError branch is load-bearing (without it, the re-overlay effect would re-apply a stale overlay onto the just-rolled-back cache and undo the rollback). A regression that reordered those two lines would currently pass CI.

Suggested fix: Add tests that trigger the error callback:

it("rolls back to previousData when the toggle mutation fails", () => { /* ... */ });
it("does not re-apply the overlay after a failed mutation drains it", () => { /* ... */ });
it("preserves a mid-flight re-toggle when the original mutation fails", () => { /* ... */ });

The existing mock infra (mutationCallbacks capture array) is already wired to do this — call mutationCallbacks[0].onError?.(new Error("boom")) from inside act().


R4 — flushPendingChanges success path leaves overlay drained but skips local invalidation

File: src/frontend/src/modals/modelProviderModal/hooks/useProviderConfiguration.ts:677-682

Issue: On success, flushPendingChanges drains the overlay via clearSentOverlay but does not itself invalidate the useGetEnabledModels query. The inline comment defers this to "refreshAllModelInputs which runs after this promise resolves." That happens in src/frontend/src/modals/modelProviderModal/index.tsx:27-28:

const flushPromise = flushRef.current?.();
onClose();
await flushPromise;
refreshAllModelInputs({ silent: true });   // ← this is the implicit invalidation

The coupling is correct for the only current call site, but it's invisible: a future caller that uses flushPendingChanges without immediately following with refreshAllModelInputs will silently leave the cache in optimistic-state-without-server-confirmation.

Why it matters: Action-at-a-distance pattern. The reviewer rule's comprehension audit asks "what would fail if a given block were removed?" — removing refreshAllModelInputs in index.tsx:28 would break flushPendingChanges's contract but no test or type declares the dependency.

Suggested fix (low-cost): Either invalidate ["useGetEnabledModels"] inside flushPendingChanges after success (matches flushModelToggles.onSettled), or rename to flushPendingChangesWithoutInvalidation and add a Why: comment naming the caller that owns the invalidation. The former is preferable — invalidations are idempotent and cheap.


✅ Action checklist for the author

Important (preferably this PR):

  • I1 — Extract toggle-queue logic out of useProviderConfiguration.ts (currently 821 lines, hard limit 500). Suggested module: useModelToggleQueue.ts. Acceptable as a same-day follow-up if reviewer agrees.

Recommended (can ship as follow-up PR):

  • R1 — DRY the flushModelToggles / flushPendingChanges shared skeleton behind two small helpers (buildAndConsumeToggleBatch, rollbackToggleBatch).
  • R2 — Add a one-line Why: callout on the drifted check explicitly naming it as the re-render loop guard.
  • R3 — Add adversarial tests for the mutation error path (rollback to previousData, no re-overlay after drain, mid-flight re-toggle survival).
  • R4 — Either invalidate ["useGetEnabledModels"] inside flushPendingChanges on success, or document the implicit dependency on the caller's refreshAllModelInputs.

Test suggestions (post-R3):

  • it("rolls back to previousData when the toggle mutation fails")
  • it("does not re-apply the overlay after a failed mutation drains it")
  • it("preserves a mid-flight re-toggle when the original mutation fails")
  • it("invalidates useGetEnabledModels after a successful flushPendingChanges") (only if R4 is taken in this PR)

What was checked and passed

For the record — the following gates from REVIEWER_RULE.md were verified and passed:

  • PII in logs — no email, first_name, last_name, user.email patterns introduced anywhere. Error toasts surface error.response.data.detail to the UI; nothing is logged.
  • Security mindset (the Five Questions) — the PR's threat surface is a UI cache layer, not authentication/authorization/payments. No trust-without-verify boundary is introduced; the optimistic cache update is purely client-side and is reconciled by invalidateQueries on onSettled.
  • Comprehension audit — every non-obvious block has a Why: comment block explaining the design choice (split buffers, drain-before-rollback ordering, re-overlay loop guard, dual-fetch loading gate). The author's PR description articulates the root cause and the fix in standalone prose.
  • AI-generated-code lens — N/A (no security-critical path is in this diff; the code is UI cache management).
  • AI runtime resilience — N/A (no LLM call introduced).
  • DRYgetErrorMessage is a net DRY improvement, replacing four duplicated error?.response?.data?.detail || error?.message chains. R1 calls out the remaining duplication.
  • Cyclomatic complexity — every new function is ≤ CC 6 (clearSentOverlay ≈ 3, flushModelToggles ≈ 5, handleModelToggle ≈ 4, re-overlay effect ≈ 6).
  • Nesting depth — max 3 levels in any new function.
  • Strong typinganyunknown migration across four catch blocks via getErrorMessage. The remaining as { response?: ... } cast inside getErrorMessage is acceptable: an Axios error shape is the only realistic input at those sites, and the cast is type-narrowed and immediately defended by optional chaining.
  • Tests — coverage on both fixes; happy + structural paths covered. (R3 flags the missing error-path coverage.)
  • Loading-gate fix — the ModelInputComponent change correctly adds isFetchingEnabledModels to both the predicate and the dependency array; the new test simulates the exact race (providers settles first while enabled-models is still in flight) and asserts the loading button persists until both settle.
  • No console.log, print(), or eval introduced anywhere in the diff.
  • No Fixes #N / Closes #N / Resolves #N keywords in this document.

Reviewer's notes

  • The split between overlayToggles (the union of every un-confirmed toggle, for UI protection across refetches) and unsentToggles (the strict subset awaiting next flush) is the correct shape. A single buffer would either resend in-flight payloads on each new toggle, or lose UI protection mid-flight. Both alternatives are documented in the block comment. This is exemplary Why: documentation.
  • The cancelQueries placement at the top of handleModelToggle matches the canonical TanStack Query optimistic-update pattern. Pairing it with the re-overlay effect (to cover the window AFTER click, when cancelQueries no longer protects) is the correct two-layer defense.
  • The dual clearSentOverlay call (both onError and onSettled) initially looked redundant, but the onError call is load-bearing: it must run BEFORE the setQueryData(previousData) rollback so the re-overlay effect can't re-apply the stale overlay over the rollback. The current code orders these correctly; R2's loop-guard comment partially covers this, but a small parallel comment in the onError block would also help.

Addresses review I1 (file size + mixed responsibilities) and R1, R2,
R3, R4 in one pass.

I1 — Extract toggle-queue logic out of useProviderConfiguration. The
parent hook drops from 821 lines to 601 and now owns a single
responsibility (variable CRUD + provider lifecycle). The toggle queue
— overlay/unsent buffers, debounced flush, awaitable flush, optimistic
cache management, re-overlay effect — lives in a new 292-line
useModelToggleQueue.ts with one clear responsibility.

R1 — Inside the new hook, DRY the two flush variants behind shared
helpers:
  - ``buildAndConsumeToggleBatch()`` snapshots unsentToggles into the
    mutation payload, captures the pre-toggle cache for rollback, and
    drains unsentToggles atomically.
  - ``rollbackToggleBatch()`` drains overlay BEFORE restoring
    previousData (load-bearing order — otherwise the re-overlay effect
    would re-apply the stale overlay over the rollback).
The two flush callers now differ only in ``mutate`` vs awaitable
``mutateAsync`` plumbing.

R2 — Explicit loop-guard comment on the drift check inside the
re-overlay effect. Names the ``drifted`` short-circuit as the
termination condition so a future refactor that "simplifies" to an
unconditional re-apply doesn't silently introduce a render loop.

R3 — Adversarial coverage for the mutation error path:
  - ``rolls back to previousData when the toggle mutation fails``
  - ``does not re-apply the overlay after a failed mutation drains it``
    (asserts the drain-before-rollback ordering)
  - ``preserves a mid-flight re-toggle when the original mutation
    fails`` (asserts a user re-toggle survives the originating
    mutation's failure)

R4 — ``flushPendingChanges`` now invalidates the affected queries
inline on success, instead of relying on the caller's
``refreshAllModelInputs`` for the load-bearing invalidation. The
caller's refresh remains as an additive per-node template refresh.

Test infra: moved toggle-queue tests from
``useProviderConfiguration.test.tsx`` to a dedicated
``useModelToggleQueue.test.tsx`` that exercises the extracted hook
directly. Debounce mock changed from synchronous pass-through to
explicit ``runDebounced()`` so individual tests can choose whether to
exercise the debounced path or the awaitable ``flushPendingChanges``
path.

95/95 tests pass (87 prior + new R3 coverage + R4 success-path
invalidation).
@erichare
Copy link
Copy Markdown
Collaborator Author

Addressed I1 + R1, R2, R3, R4 in 9c3b0e6.

I1 — file size + mixed responsibilities
Extracted the toggle queue into `useModelToggleQueue.ts` (292 lines, single responsibility). `useProviderConfiguration.ts` drops from 821 → 601 lines and now owns only variable CRUD + provider lifecycle.

R1 — DRY the flush helpers
Inside the new hook, the two flush variants now share `buildAndConsumeToggleBatch()` (snapshots payload, captures rollback data, drains unsent atomically) and `rollbackToggleBatch()` (drains overlay BEFORE restoring `previousData` — load-bearing order). The callers differ only in `mutate` vs awaitable `mutateAsync` plumbing.

R2 — explicit loop-guard comment
Added a paragraph above the `drifted` check naming it as the termination condition: future refactors that "simplify" to an unconditional re-apply would silently introduce a render loop.

R3 — adversarial error-path coverage
Three new tests directly target the rollback path:

  • `rolls back to previousData when the toggle mutation fails`
  • `does not re-apply the overlay after a failed mutation drains it` (asserts drain-before-rollback ordering)
  • `preserves a mid-flight re-toggle when the original mutation fails`

R4 — invalidate inside flushPendingChanges
`flushPendingChanges` now invalidates `useGetEnabledModels` and `useGetModelProviders` inline on success. The modal's `refreshAllModelInputs` is now an additive per-node template refresh, not the load-bearing invalidation. A test (`invalidates useGetEnabledModels on success without relying on the caller`) pins the new contract.

Test infra: moved toggle tests from `useProviderConfiguration.test.tsx` to a dedicated `useModelToggleQueue.test.tsx` (exercising the extracted hook directly). Debounce mock changed from synchronous pass-through to explicit `runDebounced()` so each test chooses whether to exercise the debounced path or the awaitable `flushPendingChanges` path.

95/95 tests pass; biome + staged-file no-any pre-commit pass.

@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels May 13, 2026
@erichare erichare requested a review from Cristhianzl May 13, 2026 18:43
Copy link
Copy Markdown
Member

@Cristhianzl Cristhianzl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@github-actions github-actions Bot added the lgtm This PR has been approved by a maintainer label May 13, 2026
@erichare erichare merged commit cd4aa71 into release-1.9.3 May 13, 2026
196 of 198 checks passed
@erichare erichare deleted the fix/model-provider-toggle-bounce-and-agent-cache branch May 13, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working lgtm This PR has been approved by a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants