diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 13:18:25 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 14:41:47 +0900 |
| commit | 0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3 (patch) | |
| tree | b198da2719987ffd58e9c2633fc36684cf2ad316 /src/features/concurrency/ui | |
| parent | a59200e786f7d97d7ba5b9cd2bee9ffef531dac2 (diff) | |
| download | dispatch-web-0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3.tar.gz dispatch-web-0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3.zip | |
feat(concurrency): configurable cooldown + adaptive-headroom auto-reduce banner
Consumes the backend's concurrency-fixes (commit 2d27666) — additive to
[email protected], NO version bump. ConcurrencyStatusEntry gains
cooldownMs / autoReduced / autoReducedFrom? / notice?; new
ConcurrencyCooldownResponse / SetConcurrencyCooldownRequest + GET/PUT
/concurrency/cooldown/:providerId.
FE:
- Pure core (logic/view-model.ts): DEFAULT_COOLDOWN_MS; parseCooldownInput
(non-negative int — 0 is valid, unlike the limit); normalizeCooldown;
cooldownLabel ("350ms"/"1.2s"/"0ms (off)"); viewConcurrencyStatus extended
(cooldown + autoReduce fields; auto-reduce → warning badge, not busy);
viewAutoReduce/autoReduceNotices (banner view — prefers backend notice,
synthesizes a fallback); summarizeStatus "N auto-reduced" fragment;
normalizeConcurrencyStatus coerces new fields (immutable readonly build;
autoReducedFrom/notice only when autoReduced===true); normalizeConcurrencyCooldown.
- Types (logic/types.ts): re-export the 2 new contract types +
ConcurrencyCooldownResult + Get/SaveConcurrencyCooldown ports.
- UI: ConcurrencyCooldownRow.svelte (inline-edit cooldown + Save → PUT, seeded
via the ChatLimitField pattern); AutoReduceBanner.svelte (dismissible banner —
backend notice + "Was N, now M." + "Restore to N"); ConcurrencyView renders
the cooldown per status card + the banner section. The banner persists while
autoReduced===true, is dismissible, and clears automatically once a poll shows
autoReduced===false after a restore PUT.
- Store (store.svelte.ts): getConcurrencyCooldown + setConcurrencyCooldown
(surface 400/404/503 as ok:false; normalize at the seam) + interface decls.
- App.svelte: saveConcurrencyCooldown adapter → ConcurrencyView.
- Tests: +47 (view-model cooldown/auto-reduce/normalizers; component banner
render, cooldown PUT, restore clears banner, dismiss; store cooldown GET/PUT).
Re-synced the file: dep (bun install) + re-mirrored .dispatch/transport-contract.
reference.md. typecheck 0/0, 1048 tests green (run twice), biome clean, build OK.
Worktree env: an untracked dispatch-backend → backend symlink was created in the
worktree parent so the canonical file:../dispatch-backend/... paths resolve
(NOT committed — per the §2d/§2j worktree convention).
Diffstat (limited to 'src/features/concurrency/ui')
| -rw-r--r-- | src/features/concurrency/ui/AutoReduceBanner.svelte | 81 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyCooldownRow.svelte | 93 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.svelte | 107 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.test.ts | 339 |
4 files changed, 560 insertions, 60 deletions
diff --git a/src/features/concurrency/ui/AutoReduceBanner.svelte b/src/features/concurrency/ui/AutoReduceBanner.svelte new file mode 100644 index 0000000..132ebc7 --- /dev/null +++ b/src/features/concurrency/ui/AutoReduceBanner.svelte @@ -0,0 +1,81 @@ +<script lang="ts"> + import type { AutoReduceNotice } from "../logic/view-model"; + import type { RestoreOutcome } from "../logic/types"; + + let { + notice, + onRestore, + onDismiss, + }: { + /** The auto-reduce banner view (providerId + message + from/current limit). */ + notice: AutoReduceNotice; + /** + * "Restore to N" — PUT the limit back to `fromLimit`. Returns the outcome so + * a FAILED restore surfaces an inline error here (the banner owns its error + * display; the parent only refreshes on success). + */ + onRestore: (providerId: string, limit: number) => Promise<RestoreOutcome>; + /** Hide this banner locally (persists hidden while autoReduced stays true). */ + onDismiss: (providerId: string) => void; + } = $props(); + + let restoring = $state(false); + /** Inline restore error (e.g. "Concurrency service not available"); cleared on retry. */ + let error = $state<string | null>(null); + + async function handleRestore(): Promise<void> { + restoring = true; + error = null; + // The parent PUTs the limit + refreshes status on success; the banner clears + // once the next poll shows autoReduced===false. On failure the outcome is + // bubbled back here so the error shows inline next to the button. + const result = await onRestore(notice.providerId, notice.fromLimit); + restoring = false; + if (!result.ok) { + error = result.error; + } + } +</script> + +<div + class="alert alert-warning flex flex-col gap-2 py-2 text-xs" + role="status" + data-testid={`auto-reduce-banner-${notice.providerId}`} +> + <div class="flex items-start gap-2"> + <span class="shrink-0">⚠</span> + <div class="flex-1"> + <p>{notice.message}</p> + <p class="opacity-70"> + Was {notice.fromLimit}, now {notice.currentLimit}. + </p> + </div> + <div class="flex shrink-0 items-center gap-1"> + <!-- The "Restore to N" text stays visible while loading (only the spinner is + prepended) so the button keeps its accessible name during the PUT — a + spinner-only button loses its name for screen-reader users. --> + <button + type="button" + class="btn btn-warning btn-xs gap-1" + disabled={restoring} + onclick={handleRestore} + > + {#if restoring} + <span class="loading loading-spinner loading-xs"></span> + {/if} + Restore to {notice.fromLimit} + </button> + <button + type="button" + class="btn btn-ghost btn-xs" + aria-label={`Dismiss auto-reduce notice for ${notice.providerId}`} + onclick={() => onDismiss(notice.providerId)} + > + ✕ + </button> + </div> + </div> + {#if error} + <p class="font-mono text-error" data-testid={`restore-error-${notice.providerId}`}>{error}</p> + {/if} +</div> diff --git a/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte b/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte new file mode 100644 index 0000000..03ddec9 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte @@ -0,0 +1,93 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { parseCooldownInput } from "../logic/view-model"; + import type { SaveConcurrencyCooldown } from "../logic/types"; + + let { + providerId, + cooldownMs, + save, + }: { + /** The provider this cooldown row controls. */ + providerId: string; + /** The current per-slot release cooldown (ms) from the live status poll. */ + cooldownMs: number; + save: SaveConcurrencyCooldown; + } = $props(); + + // Inline-edit state: the raw text bound to the cooldown input. Seeded from the + // row's canonical cooldownMs, but only while the field is untouched — so a + // status-poll refresh re-syncs it without clobbering an in-flight edit. Mirrors + // the ConcurrencyLimitRow / ChatLimitField seed pattern. + let draft = $state(""); + let lastSeed = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + /** Brief "Saved." confirmation after a successful save; cleared on edit. */ + let justSaved = $state(false); + + $effect(() => { + const incoming = String(cooldownMs); + untrack(() => { + if (draft === lastSeed) draft = incoming; + lastSeed = incoming; + }); + }); + + const parsed = $derived(parseCooldownInput(draft)); + const dirty = $derived(parsed !== null && parsed !== cooldownMs); + + function onInput(): void { + justSaved = false; + error = null; + } + + async function handleSave(): Promise<void> { + if (parsed === null || parsed === cooldownMs) return; + saving = true; + error = null; + const result = await save(providerId, parsed); + saving = false; + if (result.ok) { + // Reflect the echoed cooldown back into the field immediately (the prop + // also re-asserts it via the seed effect above once the parent reloads). + draft = String(result.cooldownMs); + lastSeed = draft; + justSaved = true; + } else { + error = result.error; + } + } +</script> + +<div class="flex items-center gap-2 text-xs"> + <span class="opacity-60">cooldown</span> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-20 font-mono" + aria-label={`Release cooldown (ms) for ${providerId}`} + bind:value={draft} + oninput={onInput} + disabled={saving} + /> + <span class="opacity-50">ms</span> + <button + type="button" + class="btn btn-primary btn-xs" + aria-label={`Save cooldown for ${providerId}`} + disabled={!dirty || saving} + onclick={handleSave} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Save + {/if} + </button> + {#if error} + <span class="font-mono text-error">{error}</span> + {:else if justSaved && !dirty} + <span class="text-success">Saved.</span> + {/if} +</div> diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte index f8a2199..6078b1a 100644 --- a/src/features/concurrency/ui/ConcurrencyView.svelte +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -2,6 +2,7 @@ import { untrack } from "svelte"; import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; import { + autoReduceNotices, type Badge, parseLimitInput, providerOptions, @@ -15,8 +16,12 @@ DeleteConcurrencyLimit, LoadConcurrencyLimits, LoadConcurrencyStatus, + RestoreOutcome, + SaveConcurrencyCooldown, SaveConcurrencyLimit, } from "../logic/types"; + import AutoReduceBanner from "./AutoReduceBanner.svelte"; + import ConcurrencyCooldownRow from "./ConcurrencyCooldownRow.svelte"; import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte"; let { @@ -25,6 +30,7 @@ saveLimit, deleteLimit, loadStatus, + saveCooldown, }: { /** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */ models: readonly string[]; @@ -32,6 +38,7 @@ saveLimit: SaveConcurrencyLimit; deleteLimit: DeleteConcurrencyLimit; loadStatus: LoadConcurrencyStatus; + saveCooldown: SaveConcurrencyCooldown; } = $props(); const badgeClass: Record<Badge, string> = { @@ -136,6 +143,18 @@ return result; } + // Wrap the cooldown save so a successful PUT refreshes the live status (which + // re-carries the new `cooldownMs`). The row still gets the result to drive its + // own UI. `getCooldown` is exposed for completeness/future use (the live status + // already carries `cooldownMs`, so the row seeds from the status view). + async function cooldownSave(providerId: string, cooldownMs: number) { + const result = await saveCooldown(providerId, cooldownMs); + if (result.ok) { + void refreshStatus(); + } + return result; + } + // ── Live status (polls while mounted) ─────────────────────────────────────── let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]); let statusError = $state<string | null>(null); @@ -158,6 +177,69 @@ const statusViews = $derived(viewConcurrencyStatuses(statusEntries, now)); const statusSummary = $derived(summarizeStatus(statusEntries, now)); + // ── Auto-reduce banners (persist while autoReduced===true; dismissible) ─────── + // + // When a provider's limit is auto-reduced by a 429, `GET /concurrency/status` + // carries `autoReduced: true` (+ `autoReducedFrom` + `notice`). We render a + // banner per such provider. The banner is DISMISSIBLE: a dismissed provider + // stays hidden while it remains auto-reduced (persist-while-true), and is + // UN-dismissed the moment a poll shows it no longer auto-reduced — so a future + // auto-reduce re-shows the banner. Restoring the limit (PUT) clears + // `autoReduced` server-side → the next poll drops the banner automatically. + // + // The dismissed set is intentionally COMPONENT-LOCAL (NOT persisted to + // localStorage / a module-global): it resets on remount (sidebar view switch / + // reload). This is correct — `autoReduced` is a REAL persisted degraded state, + // so re-showing the banner on a fresh mount reminds the user. Persisting a + // dismissal across reloads would risk HIDING an ongoing degradation (a + // footgun), and AGENTS.md forbids module-global ambient state. Mirrors the + // component-local `limitsError`/`statusError` pattern. + let dismissedAutoReduce = $state<ReadonlySet<string>>(new Set()); + + const allNotices = $derived(autoReduceNotices(statusEntries)); + const visibleNotices = $derived( + allNotices.filter((n) => !dismissedAutoReduce.has(n.providerId)), + ); + + // Reconcile the dismissed set against the live auto-reduced providers: keep a + // dismissed entry ONLY while its provider is still auto-reduced. A provider + // that has been restored (no longer in `allNotices`) is dropped from the + // dismissed set so a future auto-reduce re-shows its banner. + $effect(() => { + const autoReducedIds = new Set(allNotices.map((n) => n.providerId)); + untrack(() => { + let changed = false; + const next = new Set<string>(); + for (const id of dismissedAutoReduce) { + if (autoReducedIds.has(id)) next.add(id); + else changed = true; + } + if (changed) dismissedAutoReduce = next; + }); + }); + + function dismissAutoReduce(providerId: string): void { + if (dismissedAutoReduce.has(providerId)) return; + dismissedAutoReduce = new Set([...dismissedAutoReduce, providerId]); + } + + // "Restore to N" — PUT the limit back to `autoReducedFrom` via the limits + // endpoint (a manual PUT clears `autoReduced` server-side). Refreshes limits + + // status on success; the next status poll shows `autoReduced===false` and the + // banner drops (the dismissed-set effect above un-dismisses it too). The banner + // component owns its own restoring-spinner + inline error; on FAILURE the + // outcome is bubbled back so the banner shows the error inline (instead of + // silently re-enabling the button / surfacing it only in the limits section). + async function restoreLimit(providerId: string, limit: number): Promise<RestoreOutcome> { + const result = await saveLimit(providerId, limit); + if (result.ok) { + void refreshLimits(); + void refreshStatus(); + return { ok: true }; + } + return { ok: false, error: result.error }; + } + async function refreshStatus(): Promise<void> { if (statusInFlight) return; statusInFlight = true; @@ -192,6 +274,19 @@ </script> <div class="flex flex-col gap-4"> + <!-- Auto-reduce banners (appear when a provider's limit was auto-reduced by a 429) --> + {#if visibleNotices.length > 0} + <section class="flex flex-col gap-2" aria-label="Concurrency auto-reduce notices"> + {#each visibleNotices as notice (notice.providerId)} + <AutoReduceBanner + {notice} + onRestore={restoreLimit} + onDismiss={dismissAutoReduce} + /> + {/each} + </section> + {/if} + <!-- Limits (config) --> <section class="flex flex-col gap-2"> <div class="flex items-center justify-between gap-2"> @@ -319,10 +414,22 @@ <div class="flex flex-wrap items-center justify-between gap-2 text-xs opacity-70"> <span title="In-flight slots held vs cap">{s.inFlightLabel} in flight</span> <span>{s.queuedLabel}</span> + <span title="Per-slot release cooldown">cooldown {s.cooldownLabel}</span> </div> {#if s.pausedLabel} <span class="text-xs text-warning">{s.pausedLabel}</span> {/if} + {#if s.autoReduced} + <span class="text-xs text-warning"> + Limit auto-reduced{#if s.autoReducedFrom !== null} + from {s.autoReducedFrom} to {s.limit}{/if}. + </span> + {/if} + <ConcurrencyCooldownRow + providerId={s.providerId} + cooldownMs={s.cooldownMs} + save={cooldownSave} + /> </li> {/each} </ul> diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts index 3dc8e78..854f13b 100644 --- a/src/features/concurrency/ui/ConcurrencyView.test.ts +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import type { + ConcurrencyCooldownResult, ConcurrencyDeleteResult, ConcurrencyLimitResult, ConcurrencyLimitsResult, @@ -13,35 +14,80 @@ import ConcurrencyView from "./ConcurrencyView.svelte"; // Available models → provider ids are "umans", "anthropic", "openai-compat". const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const; -// Fakes for the four injected ports. Each resolves immediately so the mount -// effect's initial load settles in a microtask (assertions await via findBy*). +// A status entry factory (defaults to a healthy limited provider). The new +// concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present. +function statusEntry(over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry { + return { + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 1, + paused: false, + cooldownMs: 350, + autoReduced: false, + ...over, + }; +} +// Fakes for the injected ports. Each resolves immediately so the mount effect's +// initial load settles in a microtask (assertions await via findBy*). The status +// list is mutable so a test can flip `autoReduced` between polls to simulate a +// restore clearing the banner. function makeFakes(opts?: { limits?: readonly { providerId: string; limit: number }[]; - status?: readonly ConcurrencyStatusEntry[]; + status?: ConcurrencyStatusEntry[]; + /** + * When set, `saveLimit` rejects with this error (returns `ok: false`) — used + * to test the auto-reduce banner's inline restore-error feedback. + */ + saveLimitError?: string; + /** + * Optional hook invoked inside `saveLimit` AFTER recording the call. Lets a + * test simulate a backend side-effect of the PUT (e.g. clearing `autoReduced` + * on the next status poll). Receives the providerId + limit + the fakes bag so + * it can mutate the status list. (A plain method reassignment would NOT reach + * the already-rendered component — the prop captured the original closure.) + */ + onSaveLimit?: ( + providerId: string, + limit: number, + self: { calls: MakeFakesCalls; setStatus: (next: ConcurrencyStatusEntry[]) => void }, + ) => void; }) { let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }]; - const status = opts?.status ?? [ - { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, - ]; + let status = opts?.status ?? [statusEntry()]; + const onSaveLimit = opts?.onSaveLimit; + const saveLimitError = opts?.saveLimitError; - const calls = { + const calls: MakeFakesCalls = { loadLimits: 0, loadStatus: 0, saves: [] as { providerId: string; limit: number }[], deletes: [] as string[], + cooldownSaves: [] as { providerId: string; cooldownMs: number }[], }; + function setStatus(next: ConcurrencyStatusEntry[]): void { + status = next; + } + return { calls, + // Allow a test to mutate the status list between polls (e.g. clear + // autoReduced after a restore to simulate the next poll). + setStatus, loadLimits: async (): Promise<ConcurrencyLimitsResult> => { calls.loadLimits++; return { ok: true, limits }; }, saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => { calls.saves.push({ providerId, limit }); + if (saveLimitError !== undefined) { + return { ok: false, error: saveLimitError }; + } // Reflect the new limit into the list the next load returns. limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }]; + if (onSaveLimit !== undefined) onSaveLimit(providerId, limit, { calls, setStatus }); return { ok: true, providerId, limit }; }, deleteLimit: async (providerId: string): Promise<ConcurrencyDeleteResult> => { @@ -53,21 +99,41 @@ function makeFakes(opts?: { calls.loadStatus++; return { ok: true, providers: status }; }, + saveCooldown: async ( + providerId: string, + cooldownMs: number, + ): Promise<ConcurrencyCooldownResult> => { + calls.cooldownSaves.push({ providerId, cooldownMs }); + // Reflect the new cooldown into the status list the next load returns. + status = status.map((s) => (s.providerId === providerId ? { ...s, cooldownMs } : s)); + return { ok: true, providerId, cooldownMs }; + }, + }; +} + +type MakeFakesCalls = { + loadLimits: number; + loadStatus: number; + saves: { providerId: string; limit: number }[]; + deletes: string[]; + cooldownSaves: { providerId: string; cooldownMs: number }[]; +}; + +function props(fakes: ReturnType<typeof makeFakes>) { + return { + models: MODELS as unknown as readonly string[], + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + saveCooldown: fakes.saveCooldown, }; } describe("ConcurrencyView", () => { it("loads + renders the configured limits and live status on mount", async () => { const fakes = makeFakes(); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); // The provider dropdown is populated from the available models' providers. const providerSelect = await screen.findByLabelText("Provider"); @@ -81,21 +147,21 @@ describe("ConcurrencyView", () => { expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1); }); + it("renders the per-provider cooldown label from the live status", async () => { + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + // The status card shows the cooldown alongside in-flight/queue. + expect(await screen.findByText(/cooldown 350ms/)).toBeInTheDocument(); + }); + it("surfaces NO loading indicator during refresh (background poll is silent — no flicker)", async () => { // The 2s status poll + post-mutation reloads are SILENT: they never toggle a // visible loading state, so the Refresh buttons are plain-text (no spinner) // and the list area never reflows mid-refresh. Regression guard for the // flicker fix (mirrors the heartbeat runs list). const fakes = makeFakes(); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); @@ -116,15 +182,7 @@ describe("ConcurrencyView", () => { it("adds a provider limit via the dropdown form (calls saveLimit + reloads)", async () => { const user = userEvent.setup(); const fakes = makeFakes({ limits: [], status: [] }); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); const providerSelect = await screen.findByLabelText("Provider"); // Choose "anthropic" from the dropdown (the list is auto-selected first). @@ -142,15 +200,7 @@ describe("ConcurrencyView", () => { it("disables Add when the limit is empty/invalid (provider is auto-selected)", async () => { const user = userEvent.setup(); const fakes = makeFakes({ limits: [], status: [] }); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); const providerSelect = await screen.findByLabelText("Provider"); // A provider is auto-selected from the dropdown. @@ -172,13 +222,7 @@ describe("ConcurrencyView", () => { it("shows no-providers + disables the dropdown when there are no models", async () => { const fakes = makeFakes({ limits: [], status: [] }); render(ConcurrencyView, { - props: { - models: [], - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, + props: { ...props(fakes), models: [] as unknown as readonly string[] }, }); const providerSelect = await screen.findByLabelText("Provider"); @@ -189,15 +233,7 @@ describe("ConcurrencyView", () => { it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => { const user = userEvent.setup(); const fakes = makeFakes(); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); // Wait for the limits to load (unique summary) before interacting. await screen.findByText(/1 limit configured/); @@ -209,7 +245,7 @@ describe("ConcurrencyView", () => { it("surfaces a load error from the limits endpoint", async () => { const failing = { - models: MODELS, + models: MODELS as unknown as readonly string[], loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({ ok: false, error: "Concurrency service not available", @@ -217,8 +253,191 @@ describe("ConcurrencyView", () => { saveLimit: async (): Promise<ConcurrencyLimitResult> => ({ ok: false, error: "noop" }), deleteLimit: async (): Promise<ConcurrencyDeleteResult> => ({ ok: false, error: "noop" }), loadStatus: async (): Promise<ConcurrencyStatusResult> => ({ ok: true, providers: [] }), + saveCooldown: async (): Promise<ConcurrencyCooldownResult> => ({ ok: false, error: "noop" }), }; render(ConcurrencyView, { props: failing }); expect(await screen.findByText("Concurrency service not available")).toBeVisible(); }); + + // ── Concurrency-fixes: auto-reduce banner + cooldown editing ──────────────── + + it("renders an auto-reduce banner (with the backend notice + Restore) when a provider is auto-reduced", async () => { + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.", + }), + ], + }); + render(ConcurrencyView, { props: props(fakes) }); + + // The banner shows the backend notice verbatim + a "Restore to 4" action. + expect(await screen.findByText(/auto-reduced to 3 after a 429/)).toBeVisible(); + expect(await screen.findByRole("button", { name: /Restore to 4/ })).toBeVisible(); + // The "Was 4, now 3." provenance line is shown. + expect(await screen.findByText(/Was 4, now 3\./)).toBeVisible(); + }); + + it("clears the banner after Restore (next status poll shows autoReduced===false)", async () => { + const user = userEvent.setup(); + // Start auto-reduced (limit 3, was 4). The restore PUT clears `autoReduced` + // server-side; the next status poll returns limit 4 + autoReduced===false → + // the banner drops. + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + onSaveLimit: (_providerId, limit, self) => { + // Simulate the backend clearing `autoReduced` on the manual PUT: the next + // status load returns the restored limit with autoReduced===false. + self.setStatus([statusEntry({ limit, autoReduced: false })]); + }, + }); + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + + // The restore PUT the limit back to the original (autoReducedFrom = 4). + expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]); + // The banner is gone (no Restore button, no notice text); the status summary + // now reflects the restored limit (2/4 in flight). + await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull(); + }); + + it("dismisses the auto-reduce banner locally while it stays auto-reduced", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + }); + render(ConcurrencyView, { props: props(fakes) }); + + await screen.findByRole("button", { name: /Restore to 4/ }); + // Dismiss the banner (hide locally — the provider is still auto-reduced). + await user.click(screen.getByLabelText("Dismiss auto-reduce notice for umans")); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull(); + }); + + it("shows an inline error in the banner when the Restore PUT fails (no silent re-enable)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + saveLimitError: "Concurrency service not available", + }); + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + + // The error surfaces INLINE in the banner (near the restore action), not + // only in the far-away limits section. The banner is still present (restore + // did not succeed) and the button re-enabled for a retry. + expect(await screen.findByTestId("restore-error-umans")).toHaveTextContent( + "Concurrency service not available", + ); + expect(screen.getByRole("button", { name: /Restore to 4/ })).toBeVisible(); + expect(screen.getByRole("button", { name: /Restore to 4/ })).not.toBeDisabled(); + // The restore PUT was attempted. + expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]); + }); + + it("clears the inline restore error on a retry that succeeds", async () => { + const user = userEvent.setup(); + // First restore fails; the second succeeds (clears autoReduced). Reassigning + // `fakes.saveLimit` BEFORE `props(fakes)` is captured would NOT reach the + // rendered component, so swap it BEFORE render here. + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + onSaveLimit: (_providerId, limit, self) => { + self.setStatus([statusEntry({ limit, autoReduced: false })]); + }, + }); + let attempts = 0; + const succeeding = fakes.saveLimit; + fakes.saveLimit = async (providerId, limit) => { + attempts++; + if (attempts === 1) return { ok: false, error: "Concurrency service not available" }; + return succeeding(providerId, limit); + }; + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + // First attempt: inline error appears. + expect(await screen.findByTestId("restore-error-umans")).toBeInTheDocument(); + + // Retry: the error clears, the banner drops (restore succeeded). + await user.click(screen.getByRole("button", { name: /Restore to 4/ })); + await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); + expect(screen.queryByTestId("restore-error-umans")).toBeNull(); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + }); + + it("edits the per-provider cooldown (PUT /concurrency/cooldown + reloads status)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + // Wait for the status card + its cooldown input (seeded with 350). + const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans"); + expect((cooldownInput as HTMLInputElement).value).toBe("350"); + + await user.clear(cooldownInput); + await user.type(cooldownInput, "500"); + await user.click(screen.getByRole("button", { name: "Save cooldown for umans" })); + + // The cooldown PUT fired with the new value. + expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "umans", cooldownMs: 500 }]); + // After save the component reloads status (which now carries cooldownMs=500). + expect(await screen.findByText(/cooldown 500ms/)).toBeInTheDocument(); + }); + + it("rejects a negative cooldown input (Save disabled — non-negative integer only)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans"); + // 0 is valid (no cooldown); a negative is not. + await user.clear(cooldownInput); + await user.type(cooldownInput, "0"); + expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeEnabled(); + + await user.clear(cooldownInput); + await user.type(cooldownInput, "-5"); + expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeDisabled(); + expect(fakes.calls.cooldownSaves).toHaveLength(0); + }); }); |
