summaryrefslogtreecommitdiffhomepage
path: root/src/features/concurrency
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/concurrency')
-rw-r--r--src/features/concurrency/index.ts63
-rw-r--r--src/features/concurrency/logic/types.ts119
-rw-r--r--src/features/concurrency/logic/view-model.test.ts670
-rw-r--r--src/features/concurrency/logic/view-model.ts487
-rw-r--r--src/features/concurrency/ui/AutoReduceBanner.svelte81
-rw-r--r--src/features/concurrency/ui/ConcurrencyLimitRow.svelte204
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte433
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts559
8 files changed, 2616 insertions, 0 deletions
diff --git a/src/features/concurrency/index.ts b/src/features/concurrency/index.ts
new file mode 100644
index 0000000..c0870e3
--- /dev/null
+++ b/src/features/concurrency/index.ts
@@ -0,0 +1,63 @@
+export type {
+ // Contract shapes re-exported for a single import surface.
+ ConcurrencyCooldownResponse,
+ ConcurrencyCooldownResult,
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitEntry,
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResponse,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ ConcurrencyStatusResult,
+ DeleteConcurrencyLimit,
+ GetConcurrencyCooldown,
+ GetConcurrencyLimit,
+ LoadConcurrencyLimits,
+ LoadConcurrencyStatus,
+ RestoreOutcome,
+ SaveConcurrencyCooldown,
+ SaveConcurrencyLimit,
+ SetConcurrencyCooldownRequest,
+ SetConcurrencyLimitRequest,
+} from "./logic/types";
+export type {
+ AutoReduceNotice,
+ Badge,
+ ConcurrencyLimitView,
+ ConcurrencyStatusView,
+} from "./logic/view-model";
+export {
+ autoReduceNotices,
+ cooldownLabel,
+ DEFAULT_COOLDOWN_MS,
+ formatPauseDuration,
+ normalizeConcurrencyCooldown,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+ normalizeLimit,
+ parseCooldownInput,
+ parseLimitInput,
+ pauseLabel,
+ providerFromModel,
+ providerOptions,
+ statusLabel,
+ summarizeLimits,
+ summarizeStatus,
+ viewAutoReduce,
+ viewConcurrencyLimit,
+ viewConcurrencyLimits,
+ viewConcurrencyStatus,
+ viewConcurrencyStatuses,
+} from "./logic/view-model";
+export { default as AutoReduceBanner } from "./ui/AutoReduceBanner.svelte";
+export { default as ConcurrencyLimitRow } from "./ui/ConcurrencyLimitRow.svelte";
+export { default as ConcurrencyView } from "./ui/ConcurrencyView.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "concurrency",
+ description: "Per-provider concurrency limits + live in-flight/queue status",
+} as const;
diff --git a/src/features/concurrency/logic/types.ts b/src/features/concurrency/logic/types.ts
new file mode 100644
index 0000000..a0c5f6b
--- /dev/null
+++ b/src/features/concurrency/logic/types.ts
@@ -0,0 +1,119 @@
+import type {
+ ConcurrencyCooldownResponse,
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitsResponse,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ SetConcurrencyCooldownRequest,
+ SetConcurrencyLimitRequest,
+} from "@dispatch/transport-contract";
+
+/**
+ * Pure core types for the concurrency feature — zero DOM, zero effects, zero
+ * Svelte.
+ *
+ * The backend tracks + limits how many concurrent token-generating API requests
+ * are in flight PER PROVIDER. When the cap is reached, additional requests queue
+ * and are granted slots oldest-agent-first; a 429 backoff PAUSES a provider's
+ * queue until `pausedUntil`. The cap is in-memory + per-provider (no persistence),
+ * managed via a plain REST surface under `/concurrency/...` provided by the
+ * `concurrency` extension. When the extension isn't loaded, the list + status
+ * endpoints return empty arrays (`{ limits: [] }` / `{ providers: [] }`); the
+ * single / PUT / DELETE endpoints return `503`.
+ *
+ * The data shapes ARE part of `@dispatch/transport-contract` (0.23.0), so they
+ * are imported directly (mirrors `mcp` / `computer`). The result types + injected
+ * ports below are FE-owned (the composition root adapts the store's HTTP calls to
+ * them). The endpoints are GLOBAL (not workspace- or conversation-scoped).
+ *
+ * Concurrency-fixes (additive, no version bump): each `ConcurrencyStatusEntry`
+ * now also carries `cooldownMs` (per-slot release cooldown, configurable +
+ * persisted), `autoReduced` (a 429 auto-reduced the limit by 1, one-way), and
+ * when auto-reduced, `autoReducedFrom` + a `notice` banner string. A manual
+ * `PUT /concurrency/limits/:providerId` clears `autoReduced`. Two new endpoints
+ * `GET`/`PUT /concurrency/cooldown/:providerId` view/change the cooldown.
+ */
+
+/** Re-export the contract shapes so consumers import a single surface. */
+export type {
+ ConcurrencyCooldownResponse,
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitsResponse,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ SetConcurrencyCooldownRequest,
+ SetConcurrencyLimitRequest,
+};
+
+/**
+ * A configured concurrency limit — one provider's cap on in-flight requests.
+ * Same shape as the contract's `ConcurrencyLimitResponse`.
+ */
+export interface ConcurrencyLimitEntry {
+ readonly providerId: string;
+ readonly limit: number;
+}
+
+// ── Result types (port outcomes; the store returns these directly) ──────────────
+
+/** Outcome of `GET /concurrency/limits` (all configured limits). */
+export type ConcurrencyLimitsResult =
+ | { readonly ok: true; readonly limits: readonly ConcurrencyLimitEntry[] }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of `GET`/`PUT /concurrency/limits/:providerId` — the configured limit
+ * for one provider. `GET` returns `404` when the provider has no limit (surfaced
+ * as `ok: false`); `PUT` returns `400` for a non-positive-integer body.
+ */
+export type ConcurrencyLimitResult =
+ | { readonly ok: true; readonly providerId: string; readonly limit: number }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `DELETE /concurrency/limits/:providerId` (remove → unlimited). */
+export type ConcurrencyDeleteResult =
+ | { readonly ok: true; readonly providerId: string }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /concurrency/status` (live status for every limited provider). */
+export type ConcurrencyStatusResult =
+ | { readonly ok: true; readonly providers: readonly ConcurrencyStatusEntry[] }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of `GET`/`PUT /concurrency/cooldown/:providerId` — the per-slot
+ * release cooldown (ms) for one provider. `GET` returns `404` when the provider
+ * has no concurrency config at all (no limit, no cooldown); `PUT` returns `400`
+ * for a non-negative-integer body. Both return `503` when the extension isn't
+ * loaded.
+ */
+export type ConcurrencyCooldownResult =
+ | { readonly ok: true; readonly providerId: string; readonly cooldownMs: number }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of an auto-reduce banner's "Restore to N" action (PUT the limit back
+ * to `autoReducedFrom` via `PUT /concurrency/limits/:providerId`). Carried back to
+ * the banner so a FAILED restore surfaces an inline error next to the button
+ * (instead of silently re-enabling the button / showing the error far away).
+ */
+export type RestoreOutcome = { readonly ok: true } | { readonly ok: false; readonly error: string };
+
+// ── Injected ports (consumer-defines-port; the composition root adapts the
+// store's HTTP calls to these shapes). ──────────────────────────────────────
+
+export type LoadConcurrencyLimits = () => Promise<ConcurrencyLimitsResult>;
+export type GetConcurrencyLimit = (providerId: string) => Promise<ConcurrencyLimitResult>;
+export type SaveConcurrencyLimit = (
+ providerId: string,
+ limit: number,
+) => Promise<ConcurrencyLimitResult>;
+export type DeleteConcurrencyLimit = (providerId: string) => Promise<ConcurrencyDeleteResult>;
+export type LoadConcurrencyStatus = () => Promise<ConcurrencyStatusResult>;
+/** `GET /concurrency/cooldown/:providerId` — read the per-slot release cooldown. */
+export type GetConcurrencyCooldown = (providerId: string) => Promise<ConcurrencyCooldownResult>;
+/** `PUT /concurrency/cooldown/:providerId` — set the per-slot release cooldown (non-negative int). */
+export type SaveConcurrencyCooldown = (
+ providerId: string,
+ cooldownMs: number,
+) => Promise<ConcurrencyCooldownResult>;
diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts
new file mode 100644
index 0000000..6e6b770
--- /dev/null
+++ b/src/features/concurrency/logic/view-model.test.ts
@@ -0,0 +1,670 @@
+import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ autoReduceNotices,
+ cooldownLabel,
+ DEFAULT_COOLDOWN_MS,
+ formatPauseDuration,
+ normalizeConcurrencyCooldown,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+ parseCooldownInput,
+ parseLimitInput,
+ pauseLabel,
+ providerFromModel,
+ providerOptions,
+ statusLabel,
+ summarizeLimits,
+ summarizeStatus,
+ viewAutoReduce,
+ viewConcurrencyLimit,
+ viewConcurrencyLimits,
+ viewConcurrencyStatus,
+ viewConcurrencyStatuses,
+} from "./view-model";
+
+const status = (over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry => ({
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 0,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ ...over,
+});
+
+// ── parseLimitInput ───────────────────────────────────────────────────────────
+
+describe("parseLimitInput", () => {
+ it("accepts positive integers", () => {
+ expect(parseLimitInput("4")).toBe(4);
+ expect(parseLimitInput(" 12 ")).toBe(12);
+ expect(parseLimitInput("1")).toBe(1);
+ });
+
+ it("rejects zero, negatives, non-integers, and garbage", () => {
+ expect(parseLimitInput("0")).toBeNull();
+ expect(parseLimitInput("-1")).toBeNull();
+ expect(parseLimitInput("4.5")).toBeNull();
+ expect(parseLimitInput("")).toBeNull();
+ expect(parseLimitInput(" ")).toBeNull();
+ expect(parseLimitInput("abc")).toBeNull();
+ expect(parseLimitInput("4abc")).toBeNull();
+ });
+});
+
+// ── parseCooldownInput (non-negative integer — 0 is valid, unlike the limit) ──
+
+describe("parseCooldownInput", () => {
+ it("accepts zero + positive integers", () => {
+ expect(parseCooldownInput("0")).toBe(0);
+ expect(parseCooldownInput("350")).toBe(350);
+ expect(parseCooldownInput(" 100 ")).toBe(100);
+ });
+
+ it("rejects negatives, non-integers, and garbage", () => {
+ expect(parseCooldownInput("-1")).toBeNull();
+ expect(parseCooldownInput("4.5")).toBeNull();
+ expect(parseCooldownInput("")).toBeNull();
+ expect(parseCooldownInput("abc")).toBeNull();
+ expect(parseCooldownInput("100ms")).toBeNull();
+ });
+});
+
+// ── cooldownLabel ─────────────────────────────────────────────────────────────
+
+describe("cooldownLabel", () => {
+ it("0 → off label", () => {
+ expect(cooldownLabel(0)).toBe("0ms (off)");
+ });
+ it("sub-second → ms", () => {
+ expect(cooldownLabel(350)).toBe("350ms");
+ expect(cooldownLabel(999)).toBe("999ms");
+ });
+ it("≥1s → seconds (trims trailing .0)", () => {
+ expect(cooldownLabel(1000)).toBe("1s");
+ expect(cooldownLabel(1500)).toBe("1.5s");
+ expect(cooldownLabel(60_000)).toBe("60s");
+ });
+});
+
+// ── providerFromModel / providerOptions ───────────────────────────────────────
+
+describe("providerFromModel", () => {
+ it("takes the part before the first slash", () => {
+ expect(providerFromModel("openai/gpt-4o")).toBe("openai");
+ expect(providerFromModel("openai-compat/gpt-4o-mini")).toBe("openai-compat");
+ });
+ it("returns the whole string when there is no slash", () => {
+ expect(providerFromModel("umans")).toBe("umans");
+ });
+});
+
+describe("providerOptions", () => {
+ it("derives distinct provider ids from models, first-seen order", () => {
+ expect(
+ providerOptions(["openai/gpt-4o", "umans/umans-glm-5.2", "openai/gpt-4o-mini"], []),
+ ).toEqual(["openai", "umans"]);
+ });
+ it("unions with providers already carrying a configured limit", () => {
+ expect(providerOptions(["openai/gpt-4o"], [{ providerId: "anthropic", limit: 4 }])).toEqual([
+ "openai",
+ "anthropic",
+ ]);
+ });
+ it("does not duplicate a provider present in both models and limits", () => {
+ expect(providerOptions(["openai/gpt-4o"], [{ providerId: "openai", limit: 4 }])).toEqual([
+ "openai",
+ ]);
+ });
+ it("ignores models whose provider prefix is empty", () => {
+ expect(providerOptions(["/model-only", "umans/x"], [])).toEqual(["umans"]);
+ });
+ it("returns [] when there are no models and no limits", () => {
+ expect(providerOptions([], [])).toEqual([]);
+ });
+});
+
+// ── pauseLabel + formatPauseDuration ───────────────────────────────────────────
+
+describe("formatPauseDuration", () => {
+ it("formats seconds / minutes+seconds / hours+minutes", () => {
+ expect(formatPauseDuration(30_000)).toBe("30s");
+ expect(formatPauseDuration(65_000)).toBe("1m 05s");
+ expect(formatPauseDuration(3_660_000)).toBe("1h 01m");
+ });
+
+ it("non-positive → resuming", () => {
+ expect(formatPauseDuration(0)).toBe("resuming");
+ expect(formatPauseDuration(-5_000)).toBe("resuming");
+ });
+});
+
+describe("pauseLabel", () => {
+ it("null when not paused", () => {
+ expect(pauseLabel(false, undefined, 0)).toBeNull();
+ expect(pauseLabel(false, 10_000, 0)).toBeNull();
+ });
+
+ it("'paused' (bare) when paused without a usable timestamp", () => {
+ expect(pauseLabel(true, undefined, 0)).toBe("paused");
+ expect(pauseLabel(true, null, 0)).toBe("paused");
+ expect(pauseLabel(true, Number.NaN, 0)).toBe("paused");
+ });
+
+ it("countdown when paused with a future timestamp", () => {
+ const now = 1_000_000;
+ expect(pauseLabel(true, now + 30_000, now)).toBe("paused — resumes in 30s");
+ expect(pauseLabel(true, now + 65_000, now)).toBe("paused — resumes in 1m 05s");
+ });
+
+ it("'paused' (bare) when the timestamp is missing, non-finite, or expired", () => {
+ expect(pauseLabel(true, 0, 1_000)).toBe("paused");
+ expect(pauseLabel(true, 1_000, 2_000)).toBe("paused");
+ expect(pauseLabel(true, Number.NaN, 0)).toBe("paused");
+ // The countdown prefix only appears with a FUTURE timestamp:
+ expect(pauseLabel(true, 2_000, 1_000)).toBe("paused — resumes in 1s");
+ });
+});
+
+// ── viewConcurrencyStatus ──────────────────────────────────────────────────────
+
+describe("viewConcurrencyStatus", () => {
+ it("serving under capacity → success badge, in-flight label, no queue", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 2, limit: 4, queued: 0 }), 0);
+ expect(v.inFlightLabel).toBe("2/4");
+ expect(v.queuedLabel).toBe("no queue");
+ expect(v.pausedLabel).toBeNull();
+ expect(v.badge).toBe("success");
+ expect(v.busy).toBe(false);
+ });
+
+ it("at capacity with a queue → warning badge + busy (spinner)", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 3 }), 0);
+ expect(v.inFlightLabel).toBe("4/4");
+ expect(v.queuedLabel).toBe("3 queued");
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ });
+
+ it("idle (no in-flight) → neutral badge, not busy", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 0, limit: 4, queued: 0 }), 0);
+ expect(v.badge).toBe("neutral");
+ expect(v.busy).toBe(false);
+ expect(v.inFlightLabel).toBe("0/4");
+ });
+
+ it("paused → warning badge + pause countdown label", () => {
+ const now = 1_000_000;
+ const v = viewConcurrencyStatus(
+ status({ paused: true, pausedUntil: now + 30_000, inFlight: 4, limit: 4, queued: 3 }),
+ now,
+ );
+ expect(v.paused).toBe(true);
+ expect(v.pausedLabel).toBe("paused — resumes in 30s");
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ });
+
+ it("at capacity but no queue → success (busy only when queuing)", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 0 }), 0);
+ expect(v.badge).toBe("success");
+ expect(v.busy).toBe(false);
+ });
+
+ it("normalizes garbage counts to 0 and a malformed limit to 1", () => {
+ const v = viewConcurrencyStatus(
+ {
+ providerId: "x",
+ limit: -3,
+ inFlight: Number.NaN,
+ queued: "oops" as unknown as number,
+ paused: false,
+ cooldownMs: Number.NaN,
+ autoReduced: false,
+ },
+ 0,
+ );
+ expect(v.limit).toBe(1);
+ expect(v.inFlight).toBe(0);
+ expect(v.queued).toBe(0);
+ expect(v.inFlightLabel).toBe("0/1");
+ expect(v.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
+ });
+
+ it("viewConcurrencyStatuses maps a list preserving order", () => {
+ const views = viewConcurrencyStatuses(
+ [status({ providerId: "a" }), status({ providerId: "b" })],
+ 0,
+ );
+ expect(views.map((v) => v.providerId)).toEqual(["a", "b"]);
+ });
+
+ it("carries cooldownMs + label + autoReduced fields onto the view", () => {
+ const v = viewConcurrencyStatus(status({ cooldownMs: 1500 }), 0);
+ expect(v.cooldownMs).toBe(1500);
+ expect(v.cooldownLabel).toBe("1.5s");
+ expect(v.autoReduced).toBe(false);
+ expect(v.autoReducedFrom).toBeNull();
+ });
+
+ it("auto-reduced → warning badge (not busy) + autoReducedFrom carried", () => {
+ const v = viewConcurrencyStatus(
+ status({ limit: 3, autoReduced: true, autoReducedFrom: 4, inFlight: 0 }),
+ 0,
+ );
+ expect(v.autoReduced).toBe(true);
+ expect(v.autoReducedFrom).toBe(4);
+ expect(v.badge).toBe("warning");
+ // autoReduced alone does NOT flip busy (a reduced limit still admits agents).
+ expect(v.busy).toBe(false);
+ });
+});
+
+// ── statusLabel (the row's status badge word) ──────────────────────────────────
+
+describe("statusLabel", () => {
+ it("idle (no in-flight) → Idle", () => {
+ expect(
+ statusLabel(viewConcurrencyStatus(status({ inFlight: 0, limit: 4, queued: 0 }), 0)),
+ ).toBe("Idle");
+ });
+
+ it("serving under capacity → Active", () => {
+ expect(
+ statusLabel(viewConcurrencyStatus(status({ inFlight: 2, limit: 4, queued: 0 }), 0)),
+ ).toBe("Active");
+ });
+
+ it("at capacity with a queue → At capacity", () => {
+ expect(
+ statusLabel(viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 3 }), 0)),
+ ).toBe("At capacity");
+ });
+
+ it("at capacity but no queue → Active (not At capacity)", () => {
+ expect(
+ statusLabel(viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 0 }), 0)),
+ ).toBe("Active");
+ });
+
+ it("paused → Paused (regardless of in-flight/queue)", () => {
+ expect(
+ statusLabel(
+ viewConcurrencyStatus(status({ paused: true, inFlight: 4, limit: 4, queued: 3 }), 0),
+ ),
+ ).toBe("Paused");
+ });
+});
+
+// ── viewAutoReduce / autoReduceNotices (the auto-reduce banner view) ───────────
+
+describe("viewAutoReduce", () => {
+ it("returns null when not auto-reduced", () => {
+ expect(viewAutoReduce(status({ autoReduced: false }))).toBeNull();
+ });
+
+ it("uses the backend notice verbatim + carries from/current limits", () => {
+ const notice = viewAutoReduce(
+ status({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ );
+ expect(notice).toEqual({
+ providerId: "umans",
+ message: "Concurrency limit auto-reduced to 3 after a 429.",
+ fromLimit: 4,
+ currentLimit: 3,
+ });
+ });
+
+ it("synthesizes a fallback notice when the backend notice is absent/empty", () => {
+ expect(viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4 }))).toEqual({
+ providerId: "umans",
+ message: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.",
+ fromLimit: 4,
+ currentLimit: 3,
+ });
+ expect(
+ viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4, notice: "" })),
+ ).not.toBeNull();
+ });
+
+ it("falls back to currentLimit+1 when autoReducedFrom is missing/garbage", () => {
+ const notice = viewAutoReduce(status({ limit: 3, autoReduced: true }));
+ expect(notice?.fromLimit).toBe(4); // 3 + 1
+ });
+});
+
+describe("autoReduceNotices", () => {
+ it("collects one banner per auto-reduced provider (input order), empty when none", () => {
+ expect(autoReduceNotices([status({ providerId: "a" })])).toEqual([]);
+ const out = autoReduceNotices([
+ status({ providerId: "a", autoReduced: true, autoReducedFrom: 4, limit: 3 }),
+ status({ providerId: "b" }),
+ status({ providerId: "c", autoReduced: true, autoReducedFrom: 2, limit: 1 }),
+ ]);
+ expect(out.map((n) => n.providerId)).toEqual(["a", "c"]);
+ expect(out[1]?.fromLimit).toBe(2);
+ });
+});
+
+// ── viewConcurrencyLimit ───────────────────────────────────────────────────────
+
+describe("viewConcurrencyLimit", () => {
+ it("passes through id + normalizes the limit", () => {
+ const v = viewConcurrencyLimit({ providerId: "umans", limit: 4 });
+ expect(v.providerId).toBe("umans");
+ expect(v.limit).toBe(4);
+ });
+
+ it("clamps a malformed limit to 1", () => {
+ expect(viewConcurrencyLimit({ providerId: "x", limit: 0 }).limit).toBe(1);
+ expect(viewConcurrencyLimit({ providerId: "x", limit: -2 }).limit).toBe(1);
+ expect(viewConcurrencyLimit({ providerId: "x", limit: 2.9 }).limit).toBe(2);
+ });
+
+ it("viewConcurrencyLimits maps a list preserving order", () => {
+ const views = viewConcurrencyLimits([
+ { providerId: "a", limit: 1 },
+ { providerId: "b", limit: 2 },
+ ]);
+ expect(views.map((v) => v.providerId)).toEqual(["a", "b"]);
+ });
+});
+
+// ── summarizeLimits / summarizeStatus ──────────────────────────────────────────
+
+describe("summarizeLimits", () => {
+ it("empty → No limits configured", () => {
+ expect(summarizeLimits([])).toBe("No limits configured");
+ });
+ it("counts limits (singular/plural)", () => {
+ expect(summarizeLimits([{ providerId: "a", limit: 1 }])).toBe("1 limit configured");
+ expect(
+ summarizeLimits([
+ { providerId: "a", limit: 1 },
+ { providerId: "b", limit: 2 },
+ ]),
+ ).toBe("2 limits configured");
+ });
+});
+
+describe("summarizeStatus", () => {
+ it("empty → No limits configured", () => {
+ expect(summarizeStatus([], 0)).toBe("No limits configured");
+ });
+ it("aggregates providers + in-flight totals", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 4, inFlight: 2 }),
+ status({ providerId: "b", limit: 6, inFlight: 3 }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 5/10 in flight");
+ });
+ it("includes queued + paused fragments only when non-zero", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 4, inFlight: 4, queued: 2 }),
+ status({
+ providerId: "b",
+ limit: 4,
+ inFlight: 1,
+ queued: 0,
+ paused: true,
+ pausedUntil: 1000,
+ }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 5/8 in flight · 2 queued · 1 paused");
+ });
+ it("singular provider", () => {
+ expect(summarizeStatus([status({ providerId: "a", limit: 4, inFlight: 1 })], 0)).toBe(
+ "1 provider · 1/4 in flight",
+ );
+ });
+ it("includes an auto-reduced fragment only when non-zero", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 3, inFlight: 1, autoReduced: true, autoReducedFrom: 4 }),
+ status({ providerId: "b", limit: 4, inFlight: 1 }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 2/7 in flight · 1 auto-reduced");
+ });
+});
+
+// ── Network-seam normalizers ───────────────────────────────────────────────────
+
+describe("normalizeConcurrencyLimits", () => {
+ it("coerces a well-formed body", () => {
+ const limits = normalizeConcurrencyLimits({
+ limits: [
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ],
+ });
+ expect(limits).toEqual([
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ]);
+ });
+
+ it("non-array / missing limits → []", () => {
+ expect(normalizeConcurrencyLimits({})).toEqual([]);
+ expect(normalizeConcurrencyLimits({ limits: "nope" })).toEqual([]);
+ expect(normalizeConcurrencyLimits(null)).toEqual([]);
+ expect(normalizeConcurrencyLimits(undefined)).toEqual([]);
+ });
+
+ it("drops entries without a provider id + clamps limits", () => {
+ const limits = normalizeConcurrencyLimits({
+ limits: [
+ { providerId: "umans", limit: 4 },
+ { providerId: "", limit: 9 },
+ { providerId: 123, limit: 1 },
+ { limit: 2 },
+ { providerId: "anthropic", limit: -5 },
+ ],
+ });
+ expect(limits).toEqual([
+ { providerId: "umans", limit: 4 },
+ { providerId: "anthropic", limit: 1 },
+ ]);
+ });
+});
+
+describe("normalizeConcurrencyLimit", () => {
+ it("coerces a well-formed single response", () => {
+ expect(normalizeConcurrencyLimit({ providerId: "umans", limit: 4 })).toEqual({
+ providerId: "umans",
+ limit: 4,
+ });
+ });
+ it("null when the provider id is missing/non-string", () => {
+ expect(normalizeConcurrencyLimit({ limit: 4 })).toBeNull();
+ expect(normalizeConcurrencyLimit({ providerId: "", limit: 4 })).toBeNull();
+ expect(normalizeConcurrencyLimit(null)).toBeNull();
+ });
+ it("clamps a malformed limit to 1", () => {
+ expect(normalizeConcurrencyLimit({ providerId: "x", limit: 0 })).toEqual({
+ providerId: "x",
+ limit: 1,
+ });
+ });
+});
+
+describe("normalizeConcurrencyStatus", () => {
+ it("coerces a well-formed body, preserving pausedUntil only when present", () => {
+ const now = Date.now();
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ {
+ providerId: "openai-compat",
+ limit: 5,
+ inFlight: 5,
+ queued: 3,
+ paused: true,
+ pausedUntil: now,
+ },
+ ],
+ });
+ expect(providers).toHaveLength(2);
+ const [first, second] = providers;
+ expect(first).toEqual({
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ });
+ expect(first !== undefined && !("pausedUntil" in first)).toBe(true);
+ expect(second).toEqual({
+ providerId: "openai-compat",
+ limit: 5,
+ inFlight: 5,
+ queued: 3,
+ paused: true,
+ pausedUntil: now,
+ cooldownMs: 350,
+ autoReduced: false,
+ });
+ });
+
+ it("non-array / missing providers → []", () => {
+ expect(normalizeConcurrencyStatus({})).toEqual([]);
+ expect(normalizeConcurrencyStatus({ providers: 42 })).toEqual([]);
+ expect(normalizeConcurrencyStatus(null)).toEqual([]);
+ });
+
+ it("drops entries without a provider id + clamps counts", () => {
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ { providerId: "", inFlight: 1 },
+ { limit: 2 },
+ { providerId: 9, inFlight: 0 },
+ { providerId: "x", limit: -1, inFlight: "bad", queued: null, paused: "yes" },
+ ],
+ });
+ expect(providers).toEqual([
+ {
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ },
+ {
+ providerId: "x",
+ limit: 1,
+ inFlight: 0,
+ queued: 0,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ },
+ ]);
+ });
+
+ it("omits pausedUntil when it is not a finite number", () => {
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "a", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: "x" },
+ { providerId: "b", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: null },
+ ],
+ });
+ for (const p of providers) expect("pausedUntil" in p).toBe(false);
+ });
+
+ it("coerces cooldownMs (default 350) + carries auto-reduce fields only when true", () => {
+ const [reduced, healthy] = normalizeConcurrencyStatus({
+ providers: [
+ {
+ providerId: "umans",
+ limit: 3,
+ inFlight: 1,
+ queued: 0,
+ paused: false,
+ cooldownMs: 500,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "auto-reduced to 3 after a 429.",
+ },
+ { providerId: "openai", limit: 4, inFlight: 0, queued: 0, paused: false },
+ ],
+ });
+ expect(reduced?.cooldownMs).toBe(500);
+ expect(reduced?.autoReduced).toBe(true);
+ expect(reduced?.autoReducedFrom).toBe(4);
+ expect(reduced?.notice).toBe("auto-reduced to 3 after a 429.");
+ // Healthy entry: cooldownMs defaults to 350 when absent; auto-reduce fields
+ // are NOT present (they are only included when autoReduced===true).
+ expect(healthy?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
+ expect(healthy?.autoReduced).toBe(false);
+ expect(healthy && "autoReducedFrom" in healthy).toBe(false);
+ expect(healthy && "notice" in healthy).toBe(false);
+ });
+
+ it("drops autoReducedFrom/notice when autoReduced is false (even if present in JSON)", () => {
+ const [p] = normalizeConcurrencyStatus({
+ providers: [
+ {
+ providerId: "x",
+ limit: 4,
+ inFlight: 0,
+ queued: 0,
+ paused: false,
+ autoReduced: false,
+ autoReducedFrom: 9,
+ notice: "stale",
+ },
+ ],
+ });
+ expect(p?.autoReduced).toBe(false);
+ expect(p && "autoReducedFrom" in p).toBe(false);
+ expect(p && "notice" in p).toBe(false);
+ });
+});
+
+// ── normalizeConcurrencyCooldown ───────────────────────────────────────────────
+
+describe("normalizeConcurrencyCooldown", () => {
+ it("coerces a well-formed body", () => {
+ expect(normalizeConcurrencyCooldown({ providerId: "umans", cooldownMs: 500 })).toEqual({
+ providerId: "umans",
+ cooldownMs: 500,
+ });
+ });
+
+ it("defaults a malformed/absent cooldownMs to 350", () => {
+ expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: -1 })?.cooldownMs).toBe(
+ DEFAULT_COOLDOWN_MS,
+ );
+ expect(normalizeConcurrencyCooldown({ providerId: "x" })?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
+ expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: "fast" })?.cooldownMs).toBe(
+ DEFAULT_COOLDOWN_MS,
+ );
+ });
+
+ it("returns null for a missing/malformed providerId", () => {
+ expect(normalizeConcurrencyCooldown({ cooldownMs: 350 })).toBeNull();
+ expect(normalizeConcurrencyCooldown({ providerId: "", cooldownMs: 350 })).toBeNull();
+ expect(normalizeConcurrencyCooldown(null)).toBeNull();
+ expect(normalizeConcurrencyCooldown({})).toBeNull();
+ });
+});
diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts
new file mode 100644
index 0000000..8a37198
--- /dev/null
+++ b/src/features/concurrency/logic/view-model.ts
@@ -0,0 +1,487 @@
+import type {
+ ConcurrencyCooldownResponse,
+ ConcurrencyStatusEntry,
+} from "@dispatch/transport-contract";
+import type { ConcurrencyLimitEntry } from "./types";
+
+/**
+ * Pure view-models for the concurrency feature — zero DOM, zero effects, zero
+ * Svelte. Maps backend `ConcurrencyLimitEntry` / `ConcurrencyStatusEntry` to
+ * display shapes (badges, "2/4" in-flight labels, pause countdowns, cooldown
+ * labels, auto-reduce banners, summaries), holds the limit/cooldown-input
+ * parsing, and the network-seam normalizers the composition root coerces the
+ * untyped JSON with.
+ */
+
+export type Badge = "success" | "warning" | "error" | "neutral";
+
+/** A configured limit row shaped for display. */
+export interface ConcurrencyLimitView {
+ readonly providerId: string;
+ readonly limit: number;
+}
+
+/**
+ * A live status row shaped for display. Carries the raw counts plus pre-computed
+ * labels so the template stays thin.
+ */
+export interface ConcurrencyStatusView {
+ readonly providerId: string;
+ readonly limit: number;
+ readonly inFlight: number;
+ readonly queued: number;
+ readonly paused: boolean;
+ /** "2/4" — in-flight slots held vs the cap. */
+ readonly inFlightLabel: string;
+ /** "1 queued" / "no queue". */
+ readonly queuedLabel: string;
+ /** A pause label when paused, e.g. "paused — resumes in 30s"; null otherwise. */
+ readonly pausedLabel: string | null;
+ readonly badge: Badge;
+ /** True when paused or at capacity (show a spinner). */
+ readonly busy: boolean;
+ /** Per-slot release cooldown in ms (defensive default 350 on garbage). */
+ readonly cooldownMs: number;
+ /** "350ms" / "1.2s" / "0ms (off)" — display label for the cooldown. */
+ readonly cooldownLabel: string;
+ /** Whether the limit was auto-reduced by a 429 (one-way; user restores manually). */
+ readonly autoReduced: boolean;
+ /** The original limit before auto-reduction; null when not auto-reduced. */
+ readonly autoReducedFrom: number | null;
+}
+
+/**
+ * A view-model for the auto-reduce banner — derived from a status entry whose
+ * `autoReduced` is `true`. `message` is the backend's `notice` when present, else
+ * a synthesized fallback. `viewAutoReduce` returns this (or null) so the banner
+ * section renders without reaching into the raw entry.
+ */
+export interface AutoReduceNotice {
+ readonly providerId: string;
+ readonly message: string;
+ /** The original limit before reduction — the value "Restore to N" PUTs. */
+ readonly fromLimit: number;
+ /** The current (reduced) limit. */
+ readonly currentLimit: number;
+}
+
+// ── Limit input parsing ───────────────────────────────────────────────────────
+
+/**
+ * Parse a raw limit input into a positive integer, or `null` when it is not a
+ * valid positive integer. Accepts "4" → 4; rejects "0", "-1", "4.5", "", "abc".
+ * Drives the Add/Save button's disabled state so an invalid value never reaches
+ * the backend (the backend is still the authority — it 400s a non-positive body).
+ */
+export function parseLimitInput(value: string): number | null {
+ const trimmed = value.trim();
+ if (trimmed === "" || !/^[0-9]+$/.test(trimmed)) return null;
+ const n = Number.parseInt(trimmed, 10);
+ return Number.isFinite(n) && n >= 1 ? n : null;
+}
+
+/**
+ * Coerce an untrusted limit value into a positive integer (default 1). Used when
+ * normalizing backend responses so a malformed `limit` can never be 0/negative.
+ */
+export function normalizeLimit(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : 1;
+ const int = Math.floor(n);
+ return int >= 1 ? int : 1;
+}
+
+// ── Cooldown input parsing ────────────────────────────────────────────────────
+//
+// The per-slot release cooldown (ms) is a NON-NEGATIVE integer (0 = no cooldown,
+// instant re-admission) — unlike the limit, 0 is a VALID value. The default is
+// 350ms (the backend's server default when a limit is set but no explicit
+// cooldown was configured). It is configurable + persisted per provider via
+// `PUT /concurrency/cooldown/:providerId`.
+
+/** The server's default cooldown (ms) — used when none is explicitly set. */
+export const DEFAULT_COOLDOWN_MS = 350;
+
+/**
+ * Parse a raw cooldown input into a non-negative integer, or `null` when it is
+ * not valid. Accepts "0" → 0, "350" → 350; rejects "-1", "4.5", "", "abc".
+ * Drives the cooldown Save button's disabled state so an invalid value never
+ * reaches the backend (the backend 400s a non-negative-integer body).
+ */
+export function parseCooldownInput(value: string): number | null {
+ const trimmed = value.trim();
+ if (trimmed === "" || !/^[0-9]+$/.test(trimmed)) return null;
+ const n = Number.parseInt(trimmed, 10);
+ return Number.isFinite(n) && n >= 0 ? n : null;
+}
+
+/**
+ * Coerce an untrusted cooldown value into a non-negative integer (default
+ * {@link DEFAULT_COOLDOWN_MS}). Used when normalizing backend responses so a
+ * malformed `cooldownMs` can never be negative/non-finite.
+ */
+export function normalizeCooldown(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_COOLDOWN_MS;
+ const int = Math.floor(n);
+ return int >= 0 ? int : DEFAULT_COOLDOWN_MS;
+}
+
+/**
+ * Format a cooldown (ms) as a short display label:
+ * 0 → "0ms (off)" · <1000 → "350ms" · ≥1000 → "1.2s" (trailing ".0" trimmed).
+ */
+export function cooldownLabel(ms: number): string {
+ if (ms <= 0) return "0ms (off)";
+ if (ms < 1000) return `${ms}ms`;
+ const secs = ms / 1000;
+ const fixed = secs.toFixed(1);
+ return `${fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed}s`;
+}
+
+// ── Provider options (the Add-form dropdown) ───────────────────────────────────
+//
+// A concurrency `providerId` is the credential name that prefixes a model name
+// (`<provider>/<model>` — the same key the model picker groups by). The dropdown
+// is the UNION of providers discoverable from the available models AND providers
+// already carrying a configured limit (so a limit set out-of-band but whose
+// model list is empty still appears), in first-seen order. Models are the
+// authority; a provider with models but no limit is still selectable (Add sets it).
+
+/** The provider id prefix of a `<provider>/<model>` name (the part before the first `/`, or the whole string). */
+export function providerFromModel(full: string): string {
+ const i = full.indexOf("/");
+ return i === -1 ? full : full.slice(0, i);
+}
+
+/** Distinct provider ids to offer in the Add dropdown, first-seen order. */
+export function providerOptions(
+ models: readonly string[],
+ limits: readonly ConcurrencyLimitEntry[],
+): string[] {
+ const seen = new Set<string>();
+ const out: string[] = [];
+ const add = (p: string): void => {
+ if (p !== "" && !seen.has(p)) {
+ seen.add(p);
+ out.push(p);
+ }
+ };
+ for (const m of models) add(providerFromModel(m));
+ for (const l of limits) add(l.providerId);
+ return out;
+}
+
+// ── Status → display view ──────────────────────────────────────────────────────
+
+const NO_LIMITS = "No limits configured";
+
+/**
+ * Format a remaining-ms delta as a short pause countdown: "30s", "1m 05s",
+ * "resuming" (≤ 0). Pure via the injected `remainingMs`. The component recomputes
+ * this on each status poll (every ~2s) — a 1s ticking timer is optional.
+ */
+export function formatPauseDuration(remainingMs: number): string {
+ if (remainingMs <= 0) return "resuming";
+ const totalSec = Math.floor(remainingMs / 1000);
+ const hours = Math.floor(totalSec / 3600);
+ const mins = Math.floor((totalSec % 3600) / 60);
+ const secs = totalSec % 60;
+ if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`;
+ if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`;
+ return `${secs}s`;
+}
+
+/**
+ * The pause label for a status entry, or `null` when not paused. When paused with
+ * a future `pausedUntil`, shows "paused — resumes in 30s"; when paused without a
+ * usable timestamp, shows "paused". Pure via the injectable `now`.
+ */
+export function pauseLabel(
+ paused: boolean,
+ pausedUntil: number | null | undefined,
+ now: number = Date.now(),
+): string | null {
+ if (!paused) return null;
+ if (typeof pausedUntil === "number" && Number.isFinite(pausedUntil)) {
+ const remaining = pausedUntil - now;
+ if (remaining > 0) return `paused — resumes in ${formatPauseDuration(remaining)}`;
+ }
+ // Paused without a usable future timestamp (missing, non-finite, or already
+ // expired): the next status poll will clear `paused`. Show "paused" meanwhile.
+ return "paused";
+}
+
+/**
+ * Build a display view for a status entry. `now` is injectable for tests
+ * (defaults to `Date.now()`); the composition-root component passes nothing in
+ * production (it recomputes on each poll).
+ *
+ * `autoReduced` does NOT flip `busy` (a reduced limit still admits agents; it is
+ * a degraded-but-active state surfaced via the banner, not a spinner) — it only
+ * nudges the badge to `warning` so the row signals attention.
+ */
+export function viewConcurrencyStatus(
+ entry: ConcurrencyStatusEntry,
+ now: number = Date.now(),
+): ConcurrencyStatusView {
+ const limit = normalizeLimit(entry.limit);
+ const inFlight = clampCount(entry.inFlight);
+ const queued = clampCount(entry.queued);
+ const paused = entry.paused === true;
+ const autoReduced = entry.autoReduced === true;
+ const atCapacity = inFlight >= limit;
+ let badge: Badge;
+ if (paused) badge = "warning";
+ else if (autoReduced) badge = "warning";
+ else if (atCapacity && queued > 0) badge = "warning";
+ else if (inFlight > 0) badge = "success";
+ else badge = "neutral";
+ const cooldownMs = normalizeCooldown(entry.cooldownMs);
+ const autoReducedFrom =
+ autoReduced &&
+ typeof entry.autoReducedFrom === "number" &&
+ Number.isFinite(entry.autoReducedFrom)
+ ? normalizeLimit(entry.autoReducedFrom)
+ : null;
+ return {
+ providerId: entry.providerId,
+ limit,
+ inFlight,
+ queued,
+ paused,
+ inFlightLabel: `${inFlight}/${limit}`,
+ queuedLabel: queued === 0 ? "no queue" : `${queued} queued`,
+ pausedLabel: pauseLabel(paused, entry.pausedUntil, now),
+ badge,
+ busy: paused || (atCapacity && queued > 0),
+ cooldownMs,
+ cooldownLabel: cooldownLabel(cooldownMs),
+ autoReduced,
+ autoReducedFrom,
+ };
+}
+
+/**
+ * A short status word for a status view, for the row's status badge:
+ * "Paused" · "At capacity" (in-flight at the cap with a queue) · "Active"
+ * (in-flight > 0) · "Idle". Mirrors the badge-text branching so the template
+ * holds no branching logic.
+ */
+export function statusLabel(view: ConcurrencyStatusView): string {
+ if (view.paused) return "Paused";
+ if (view.inFlight >= view.limit && view.queued > 0) return "At capacity";
+ if (view.inFlight > 0) return "Active";
+ return "Idle";
+}
+
+/**
+ * The auto-reduce banner view for a status entry, or `null` when it is not
+ * auto-reduced. `message` prefers the backend's `notice` (verbatim, when present
+ * + non-empty); otherwise a synthesized fallback is built from
+ * `autoReducedFrom` → `limit`. `fromLimit` is the value "Restore to N" PUTs back.
+ */
+export function viewAutoReduce(entry: ConcurrencyStatusEntry): AutoReduceNotice | null {
+ if (entry.autoReduced !== true) return null;
+ const currentLimit = normalizeLimit(entry.limit);
+ const fromLimit =
+ typeof entry.autoReducedFrom === "number" && Number.isFinite(entry.autoReducedFrom)
+ ? normalizeLimit(entry.autoReducedFrom)
+ : currentLimit + 1;
+ const notice =
+ typeof entry.notice === "string" && entry.notice.length > 0
+ ? entry.notice
+ : `Concurrency limit auto-reduced to ${currentLimit} after a 429 — restore manually when ready.`;
+ return {
+ providerId: entry.providerId,
+ message: notice,
+ fromLimit,
+ currentLimit,
+ };
+}
+
+/**
+ * All auto-reduce banners across a status list (one per auto-reduced provider),
+ * in input order. Empty when none are auto-reduced.
+ */
+export function autoReduceNotices(
+ entries: readonly ConcurrencyStatusEntry[],
+): readonly AutoReduceNotice[] {
+ const out: AutoReduceNotice[] = [];
+ for (const e of entries) {
+ const n = viewAutoReduce(e);
+ if (n !== null) out.push(n);
+ }
+ return out;
+}
+
+export function viewConcurrencyStatuses(
+ entries: readonly ConcurrencyStatusEntry[],
+ now: number = Date.now(),
+): readonly ConcurrencyStatusView[] {
+ return entries.map((e) => viewConcurrencyStatus(e, now));
+}
+
+/** A display view for a configured limit entry. */
+export function viewConcurrencyLimit(entry: ConcurrencyLimitEntry): ConcurrencyLimitView {
+ return { providerId: entry.providerId, limit: normalizeLimit(entry.limit) };
+}
+
+export function viewConcurrencyLimits(
+ entries: readonly ConcurrencyLimitEntry[],
+): readonly ConcurrencyLimitView[] {
+ return entries.map(viewConcurrencyLimit);
+}
+
+// ── Summaries ──────────────────────────────────────────────────────────────────
+
+/** A one-line summary of the configured limits list, e.g. "2 limits configured". */
+export function summarizeLimits(limits: readonly ConcurrencyLimitEntry[]): string {
+ if (limits.length === 0) return NO_LIMITS;
+ return `${limits.length} limit${limits.length === 1 ? "" : "s"} configured`;
+}
+
+/**
+ * A one-line summary of the live status, e.g.
+ * "2 providers · 6/10 in flight · 1 queued · 1 paused · 1 auto-reduced". Only the
+ * queued / paused / auto-reduced fragments appear when non-zero.
+ */
+export function summarizeStatus(
+ providers: readonly ConcurrencyStatusEntry[],
+ now: number = Date.now(),
+): string {
+ if (providers.length === 0) return NO_LIMITS;
+ let inFlight = 0;
+ let limitTotal = 0;
+ let queued = 0;
+ let paused = 0;
+ let autoReduced = 0;
+ for (const p of providers) {
+ const limit = normalizeLimit(p.limit);
+ inFlight += clampCount(p.inFlight);
+ limitTotal += limit;
+ queued += clampCount(p.queued);
+ if (p.paused === true) paused += 1;
+ if (p.autoReduced === true) autoReduced += 1;
+ }
+ const parts: string[] = [];
+ parts.push(
+ `${providers.length} provider${providers.length === 1 ? "" : "s"}`,
+ `${inFlight}/${limitTotal} in flight`,
+ );
+ if (queued > 0) parts.push(`${queued} queued`);
+ if (paused > 0) parts.push(`${paused} paused`);
+ if (autoReduced > 0) parts.push(`${autoReduced} auto-reduced`);
+ // Touch `now` so the summary recomputes alongside the per-row pause countdown.
+ void now;
+ return parts.join(" · ");
+}
+
+// ── Network-seam normalization (pure; called by the composition root) ───────────
+//
+// The concurrency responses are untyped JSON at runtime. The store coerces each
+// defensively HERE (pure + tested) — a malformed/partial backend value (e.g. the
+// extension returning `{}`) can never crash the renderer. Mirrors the
+// `normalizeHeartbeatConfig` / inline `Array.isArray(data.servers)` guards.
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return value !== null && typeof value === "object";
+}
+
+function asString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+/** Coerce a non-negative count field to a non-negative integer (0 on garbage). */
+function clampCount(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : 0;
+ const int = Math.floor(n);
+ return int >= 0 ? int : 0;
+}
+
+/** Coerce an untrusted `GET /concurrency/limits` body into a typed limit list. */
+export function normalizeConcurrencyLimits(data: unknown): readonly ConcurrencyLimitEntry[] {
+ if (!isRecord(data) || !Array.isArray(data.limits)) return [];
+ const limits = data.limits as readonly unknown[];
+ return limits
+ .filter((r): r is Record<string, unknown> => isRecord(r))
+ .map((r) => ({
+ providerId: asString(r.providerId) ?? "",
+ limit: normalizeLimit(r.limit),
+ }))
+ .filter((r) => r.providerId !== "");
+}
+
+/** Coerce an untrusted `GET`/`PUT /concurrency/limits/:id` body into a limit, or null. */
+export function normalizeConcurrencyLimit(data: unknown): ConcurrencyLimitEntry | null {
+ if (!isRecord(data)) return null;
+ const providerId = asString(data.providerId);
+ if (providerId === null) return null;
+ return { providerId, limit: normalizeLimit(data.limit) };
+}
+
+/**
+ * Coerce an untrusted `GET /concurrency/status` body into a typed status list.
+ * `pausedUntil` is included only when it is a finite number (it is absent when
+ * not paused). `cooldownMs` (default 350) + `autoReduced` are always coerced;
+ * `autoReducedFrom` + `notice` are included only when `autoReduced` is true (and
+ * well-formed), mirroring the backend's "present only when auto-reduced" contract.
+ */
+export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyStatusEntry[] {
+ if (!isRecord(data) || !Array.isArray(data.providers)) return [];
+ const providers = data.providers as readonly unknown[];
+ return providers
+ .filter((r): r is Record<string, unknown> => isRecord(r))
+ .map((r): ConcurrencyStatusEntry => {
+ const providerId = asString(r.providerId) ?? "";
+ const limit = normalizeLimit(r.limit);
+ const inFlight = clampCount(r.inFlight);
+ const queued = clampCount(r.queued);
+ const paused = r.paused === true;
+ const cooldownMs = normalizeCooldown(r.cooldownMs);
+ const autoReduced = r.autoReduced === true;
+ // Build immutably (the contract fields are readonly): start with the always-
+ // present fields, then layer the optional `pausedUntil` (finite number only)
+ // + the auto-reduce-only `autoReducedFrom`/`notice` (present only when true).
+ let entry: ConcurrencyStatusEntry = {
+ providerId,
+ limit,
+ inFlight,
+ queued,
+ paused,
+ cooldownMs,
+ autoReduced,
+ };
+ if (typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil)) {
+ entry = { ...entry, pausedUntil: r.pausedUntil };
+ }
+ if (autoReduced) {
+ // Accumulate the auto-reduce-only optionals into a plain record (the
+ // contract fields are readonly, so we can't mutate a typed partial —
+ // collect then spread into a fresh entry).
+ const patch: { autoReducedFrom?: number; notice?: string } = {};
+ if (typeof r.autoReducedFrom === "number" && Number.isFinite(r.autoReducedFrom)) {
+ patch.autoReducedFrom = normalizeLimit(r.autoReducedFrom);
+ }
+ if (typeof r.notice === "string" && r.notice.length > 0) {
+ patch.notice = r.notice;
+ }
+ if (patch.autoReducedFrom !== undefined || patch.notice !== undefined) {
+ entry = { ...entry, ...patch };
+ }
+ }
+ return entry;
+ })
+ .filter((r) => r.providerId !== "");
+}
+
+/**
+ * Coerce an untrusted `GET`/`PUT /concurrency/cooldown/:providerId` body into a
+ * typed cooldown response, or `null` when it is malformed (missing/malformed
+ * `providerId` or `cooldownMs`). The composition root surfaces a 404/400/503 as
+ * `ok: false` separately; this only defends the success body.
+ */
+export function normalizeConcurrencyCooldown(data: unknown): ConcurrencyCooldownResponse | null {
+ if (!isRecord(data)) return null;
+ const providerId = asString(data.providerId);
+ if (providerId === null) return null;
+ return { providerId, cooldownMs: normalizeCooldown(data.cooldownMs) };
+}
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/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
new file mode 100644
index 0000000..bf06ac0
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
@@ -0,0 +1,204 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import {
+ DEFAULT_COOLDOWN_MS,
+ parseCooldownInput,
+ parseLimitInput,
+ statusLabel,
+ type Badge,
+ type ConcurrencyLimitView,
+ type ConcurrencyStatusView,
+ } from "../logic/view-model";
+ import type {
+ DeleteConcurrencyLimit,
+ SaveConcurrencyCooldown,
+ SaveConcurrencyLimit,
+ } from "../logic/types";
+
+ let {
+ limit,
+ status,
+ save,
+ saveCooldown,
+ remove,
+ }: {
+ /** The configured limit row (providerId + current limit). */
+ limit: ConcurrencyLimitView;
+ /** The provider's live status view (in-flight/queue/badge), or null when no
+ * status entry exists yet. Drives the status line + seeds the cooldown input. */
+ status: ConcurrencyStatusView | null;
+ save: SaveConcurrencyLimit;
+ saveCooldown: SaveConcurrencyCooldown;
+ remove: DeleteConcurrencyLimit;
+ } = $props();
+
+ // The badge→color map (presentational). Mirrors the old status-card mapping.
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
+
+ // The cooldown input seed: the live cooldown when a status entry exists, else
+ // the server default (350).
+ const cooldownMs = $derived(status?.cooldownMs ?? DEFAULT_COOLDOWN_MS);
+
+ // Inline-edit state for the limit + cooldown inputs. Each is seeded from its
+ // canonical value, but only while untouched — so a save echo / status-poll
+ // refresh re-syncs without clobbering an in-flight edit. Mirrors the
+ // ChatLimitField seed pattern (avoids reading the prop in the $state init).
+ let limitDraft = $state("");
+ let lastLimitSeed = $state("");
+ let cooldownDraft = $state("");
+ let lastCooldownSeed = $state("");
+ let saving = $state(false);
+ let removing = $state(false);
+ let error = $state<string | null>(null);
+ /** Brief "Saved" confirmation after a successful save; cleared on edit. */
+ let justSaved = $state(false);
+
+ $effect(() => {
+ const incomingLimit = String(limit.limit);
+ const incomingCooldown = String(cooldownMs);
+ untrack(() => {
+ if (limitDraft === lastLimitSeed) limitDraft = incomingLimit;
+ lastLimitSeed = incomingLimit;
+ if (cooldownDraft === lastCooldownSeed) cooldownDraft = incomingCooldown;
+ lastCooldownSeed = incomingCooldown;
+ });
+ });
+
+ const parsedLimit = $derived(parseLimitInput(limitDraft));
+ const parsedCooldown = $derived(parseCooldownInput(cooldownDraft));
+ const dirtyLimit = $derived(parsedLimit !== null && parsedLimit !== limit.limit);
+ const dirtyCooldown = $derived(parsedCooldown !== null && parsedCooldown !== cooldownMs);
+ const dirty = $derived(dirtyLimit || dirtyCooldown);
+
+ // Clear the "Saved" hint + any error as soon as the user edits either field.
+ function onInput(): void {
+ justSaved = false;
+ error = null;
+ }
+
+ // "Set" saves whichever field is dirty: the limit first (PUT
+ // /concurrency/limits/:id), then the cooldown (PUT /concurrency/cooldown/:id).
+ // Stops + surfaces an inline error on the first failure.
+ async function handleSet(): Promise<void> {
+ if (!dirty || saving || removing) return;
+ saving = true;
+ error = null;
+ try {
+ if (dirtyLimit && parsedLimit !== null) {
+ const r = await save(limit.providerId, parsedLimit);
+ if (!r.ok) {
+ error = r.error;
+ return;
+ }
+ // Reflect the echoed limit back immediately (the prop re-asserts it via
+ // the seed effect once the parent reloads).
+ limitDraft = String(r.limit);
+ lastLimitSeed = limitDraft;
+ }
+ if (dirtyCooldown && parsedCooldown !== null) {
+ const r = await saveCooldown(limit.providerId, parsedCooldown);
+ if (!r.ok) {
+ error = r.error;
+ return;
+ }
+ cooldownDraft = String(r.cooldownMs);
+ lastCooldownSeed = cooldownDraft;
+ }
+ justSaved = true;
+ } finally {
+ saving = false;
+ }
+ }
+
+ async function handleRemove(): Promise<void> {
+ removing = true;
+ error = null;
+ const result = await remove(limit.providerId);
+ removing = false;
+ if (!result.ok) {
+ error = result.error;
+ }
+ // On success the parent drops this row (re-loaded limits list).
+ }
+</script>
+
+<div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <!-- Line 1: provider + limit + cooldown + Set + ✕ (all on one line — nowrap so
+ the buttons never wrap; tight gap + narrow inputs keep the provider name
+ visible; the provider shrinks via flex-1 + min-w-0). -->
+ <div class="flex flex-nowrap items-center gap-1">
+ <span class="min-w-0 flex-1 truncate font-medium font-mono" title={limit.providerId}
+ >{limit.providerId}</span
+ >
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-12 min-w-0 font-mono"
+ aria-label={`Concurrency limit for ${limit.providerId}`}
+ bind:value={limitDraft}
+ oninput={onInput}
+ disabled={saving || removing}
+ />
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-14 min-w-0 font-mono"
+ aria-label={`Release cooldown (ms) for ${limit.providerId}`}
+ bind:value={cooldownDraft}
+ oninput={onInput}
+ disabled={saving || removing}
+ />
+ <span class="shrink-0 text-[10px] opacity-50">ms</span>
+ <button
+ type="button"
+ class="btn btn-primary btn-xs shrink-0"
+ aria-label={`Set concurrency for ${limit.providerId}`}
+ disabled={!dirty || saving || removing}
+ onclick={handleSet}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs shrink-0 text-error"
+ aria-label={`Remove concurrency limit for ${limit.providerId}`}
+ disabled={saving || removing}
+ onclick={handleRemove}
+ >
+ {#if removing}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ✕
+ {/if}
+ </button>
+ </div>
+
+ <!-- Line 2: in-flight count (left) + status badge (right). Hidden until the
+ first status poll for this provider lands. -->
+ {#if status !== null}
+ <div class="flex items-center justify-between gap-2 text-xs opacity-70">
+ <span title="In-flight slots held vs cap">{status.inFlightLabel} in flight</span>
+ <span class="badge badge-sm {badgeClass[status.badge]} gap-1">
+ {#if status.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {statusLabel(status)}
+ </span>
+ </div>
+ {/if}
+
+ {#if error}
+ <span class="font-mono text-xs text-error">{error}</span>
+ {:else if justSaved && !dirty}
+ <span class="text-xs text-success">Saved.</span>
+ {/if}
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte
new file mode 100644
index 0000000..aadb8d1
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyView.svelte
@@ -0,0 +1,433 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+ import {
+ autoReduceNotices,
+ DEFAULT_COOLDOWN_MS,
+ parseCooldownInput,
+ parseLimitInput,
+ providerOptions,
+ summarizeLimits,
+ viewConcurrencyLimits,
+ viewConcurrencyStatus,
+ type ConcurrencyStatusView,
+ } from "../logic/view-model";
+ import type {
+ ConcurrencyLimitEntry,
+ DeleteConcurrencyLimit,
+ LoadConcurrencyLimits,
+ LoadConcurrencyStatus,
+ RestoreOutcome,
+ SaveConcurrencyCooldown,
+ SaveConcurrencyLimit,
+ } from "../logic/types";
+ import AutoReduceBanner from "./AutoReduceBanner.svelte";
+ import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte";
+
+ let {
+ models,
+ loadLimits,
+ saveLimit,
+ deleteLimit,
+ loadStatus,
+ saveCooldown,
+ }: {
+ /** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */
+ models: readonly string[];
+ loadLimits: LoadConcurrencyLimits;
+ saveLimit: SaveConcurrencyLimit;
+ deleteLimit: DeleteConcurrencyLimit;
+ loadStatus: LoadConcurrencyStatus;
+ saveCooldown: SaveConcurrencyCooldown;
+ } = $props();
+
+ // ── Limits (config: list / add / update / remove) ────────────────────────────
+ let limits = $state<readonly ConcurrencyLimitEntry[]>([]);
+ let limitsError = $state<string | null>(null);
+ /** True after the first load settles (gates the empty state). */
+ let hasLoadedLimits = $state(false);
+ /** Re-entrancy guard for background/silent refreshes (no UI — prevents
+ * overlapping fetches). The refresh is near-instant, so a visible loading
+ * indicator would flicker every poll/reload; it stays INVISIBLE (mirrors the
+ * heartbeat runs list). */
+ let limitsInFlight = false;
+
+ // Add-row state. The provider id is chosen from a dropdown of known providers
+ // (derived from the available models + any already-configured limit providers).
+ // The row is revealed by the "Add" button; "Set" saves it, ✕ cancels.
+ let addOpen = $state(false);
+ let newProviderId = $state("");
+ let newLimitInput = $state("");
+ let newCooldownInput = $state("");
+ let adding = $state(false);
+ let addError = $state<string | null>(null);
+
+ const providerOpts = $derived(providerOptions(models, limits));
+ const limitViews = $derived(viewConcurrencyLimits(limits));
+ const limitsSummary = $derived(summarizeLimits(limits));
+ const parsedNewLimit = $derived(parseLimitInput(newLimitInput));
+ const parsedNewCooldown = $derived(parseCooldownInput(newCooldownInput));
+ /** Only send a cooldown PUT when the user moved it off the server default. */
+ const newCooldownChanged = $derived(
+ parsedNewCooldown !== null && parsedNewCooldown !== DEFAULT_COOLDOWN_MS,
+ );
+ const canSet = $derived(
+ newProviderId !== "" &&
+ parsedNewLimit !== null &&
+ parsedNewCooldown !== null &&
+ !limits.some((l) => l.providerId === newProviderId) &&
+ !adding,
+ );
+
+ // Keep the dropdown selection valid: default to the first option, and if the
+ // selected provider is removed from the options (e.g. its limit was deleted and
+ // it has no models), fall back to the first remaining option. Runs untracked so
+ // it doesn't loop on its own assignment.
+ $effect(() => {
+ const opts = providerOpts;
+ untrack(() => {
+ if (opts.length === 0) {
+ if (newProviderId !== "") newProviderId = "";
+ return;
+ }
+ if (!opts.includes(newProviderId)) newProviderId = opts[0] ?? "";
+ });
+ });
+
+ async function refreshLimits(): Promise<void> {
+ if (limitsInFlight) return;
+ limitsInFlight = true;
+ const result = await loadLimits();
+ limitsInFlight = false;
+ hasLoadedLimits = true;
+ if (result.ok) {
+ limits = result.limits;
+ // Clear the error only on success so it stays visible (stable, no flicker)
+ // during an in-flight retry rather than vanishing mid-refresh.
+ limitsError = null;
+ } else {
+ limitsError = result.error;
+ }
+ }
+
+ function startAdd(): void {
+ addOpen = true;
+ addError = null;
+ newLimitInput = "";
+ newCooldownInput = String(DEFAULT_COOLDOWN_MS);
+ // newProviderId is kept valid (defaults to the first option) by the effect above.
+ }
+
+ function cancelAdd(): void {
+ addOpen = false;
+ addError = null;
+ newLimitInput = "";
+ newCooldownInput = "";
+ }
+
+ // "Set" on the add row: save the limit, then the cooldown (only when the user
+ // moved it off the server default of 350ms — the backend defaults to 350 when a
+ // limit is set, so an unchanged value needs no extra PUT). On full success the
+ // add row closes + the limits/status reload (the new limit appears as a row).
+ async function handleAdd(): Promise<void> {
+ if (parsedNewLimit === null || parsedNewCooldown === null || newProviderId === "") return;
+ adding = true;
+ addError = null;
+ const limitResult = await saveLimit(newProviderId, parsedNewLimit);
+ if (!limitResult.ok) {
+ adding = false;
+ addError = limitResult.error;
+ return;
+ }
+ if (newCooldownChanged) {
+ const cooldownResult = await saveCooldown(newProviderId, parsedNewCooldown);
+ adding = false;
+ if (!cooldownResult.ok) {
+ // The limit was saved (→ a row will appear after reload); the cooldown PUT
+ // failed. Surface the error but keep the add row open so it's visible. The
+ // user can edit the cooldown on the now-saved row.
+ addError = cooldownResult.error;
+ void refreshLimits();
+ void refreshStatus();
+ return;
+ }
+ } else {
+ adding = false;
+ }
+ addOpen = false;
+ newLimitInput = "";
+ newCooldownInput = "";
+ void refreshLimits();
+ void refreshStatus();
+ }
+
+ // Wrap the ports so a row's save/remove reloads the authoritative list + status
+ // on success (the row still gets the result to drive its own UI).
+ async function rowSave(providerId: string, limit: number) {
+ const result = await saveLimit(providerId, limit);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ }
+ return result;
+ }
+
+ async function rowRemove(providerId: string) {
+ const result = await deleteLimit(providerId);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ }
+ 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.
+ 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 — seeds cooldown inputs + drives the
+ // auto-reduce banners; the poll is silent, no status cards) ────────────────
+ let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]);
+ let statusError = $state<string | null>(null);
+ /** True after the first load settles (gates the empty state). */
+ let hasLoadedStatus = $state(false);
+ /** Re-entrancy guard for the 2s background poll (no UI — a visible loading
+ * indicator flickered every poll because the refresh is near-instant; it stays
+ * INVISIBLE, mirroring the heartbeat runs list). */
+ let statusInFlight = false;
+
+ // Per-provider status view (from the live status poll) so each saved limit row
+ // renders its in-flight count + status badge + seeds its cooldown input. Null
+ // when a provider has no status entry yet (the row falls back to Idle + the
+ // server-default cooldown of 350).
+ const statusByProvider = $derived.by(() => {
+ const map = new Map<string, ConcurrencyStatusView>();
+ for (const e of statusEntries) map.set(e.providerId, viewConcurrencyStatus(e));
+ return map;
+ });
+
+ // ── 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;
+ const result = await loadStatus();
+ statusInFlight = false;
+ hasLoadedStatus = true;
+ if (result.ok) {
+ statusEntries = result.providers;
+ // Clear the error only on success so it stays visible (stable, no flicker)
+ // during an in-flight retry rather than vanishing mid-poll.
+ statusError = null;
+ } else {
+ statusError = result.error;
+ }
+ }
+
+ const STATUS_POLL_MS = 2000;
+
+ // Load limits + status on mount, and poll the live status while the view is
+ // alive (so a saved limit's cooldown input re-seeds + auto-reduce banners stay
+ // fresh without a manual refresh). Runs once — no reactive deps read inside.
+ $effect(() => {
+ untrack(() => {
+ void refreshLimits();
+ void refreshStatus();
+ });
+ const h = setInterval(() => {
+ void refreshStatus();
+ }, STATUS_POLL_MS);
+ return () => clearInterval(h);
+ });
+</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) — a single list of editable rows. -->
+ <section class="flex flex-col gap-2">
+ <div class="flex items-center justify-between gap-2">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Concurrency limits</h3>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => {
+ void refreshLimits();
+ void refreshStatus();
+ }}
+ aria-label="Refresh concurrency limits"
+ >
+ Refresh
+ </button>
+ </div>
+
+ <span class="text-xs opacity-70">{limitsSummary}</span>
+
+ {#if limitsError}
+ <p class="text-xs text-error">{limitsError}</p>
+ {:else if hasLoadedLimits && limitViews.length === 0 && !addOpen}
+ <p class="text-xs opacity-60">No limits configured — providers run unlimited.</p>
+ {/if}
+
+ <ul class="flex flex-col gap-2">
+ {#each limitViews as limit (limit.providerId)}
+ <li>
+ <ConcurrencyLimitRow
+ {limit}
+ status={statusByProvider.get(limit.providerId) ?? null}
+ save={rowSave}
+ saveCooldown={cooldownSave}
+ remove={rowRemove}
+ />
+ </li>
+ {/each}
+ </ul>
+
+ <!-- Add row: an "Add" button reveals a new item (dropdown + limit + cooldown
+ + Set + ✕). Set saves the limit (+ cooldown when moved off the default);
+ ✕ cancels the draft. -->
+ {#if addOpen}
+ <div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <!-- All on one line — nowrap so the buttons never wrap (tight gap + narrow
+ inputs keep the provider dropdown visible; it shrinks via flex-1 + min-w-0). -->
+ <div class="flex flex-nowrap items-center gap-1">
+ <select
+ class="select select-bordered select-xs min-w-0 flex-1 font-mono"
+ aria-label="Provider"
+ bind:value={newProviderId}
+ disabled={adding || providerOpts.length === 0}
+ >
+ {#if providerOpts.length === 0}
+ <option value="" disabled>No providers available</option>
+ {:else}
+ {#each providerOpts as provider (provider)}
+ <option value={provider}>{provider}</option>
+ {/each}
+ {/if}
+ </select>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-12 min-w-0 font-mono"
+ placeholder="4"
+ aria-label="New concurrency limit"
+ bind:value={newLimitInput}
+ disabled={adding}
+ />
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-14 min-w-0 font-mono"
+ aria-label="New release cooldown (ms)"
+ bind:value={newCooldownInput}
+ disabled={adding}
+ />
+ <span class="shrink-0 text-[10px] opacity-50">ms</span>
+ <button
+ type="button"
+ class="btn btn-primary btn-xs shrink-0"
+ disabled={!canSet}
+ onclick={handleAdd}
+ >
+ {#if adding}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs shrink-0 text-error"
+ aria-label="Cancel add"
+ disabled={adding}
+ onclick={cancelAdd}
+ >
+ ✕
+ </button>
+ </div>
+ {#if addError}
+ <p class="font-mono text-xs text-error">{addError}</p>
+ {/if}
+ </div>
+ {:else}
+ <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={startAdd}>
+ + Add
+ </button>
+ {/if}
+ </section>
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts
new file mode 100644
index 0000000..a8163c2
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyView.test.ts
@@ -0,0 +1,559 @@
+import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+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,
+ ConcurrencyStatusResult,
+} from "../logic/types";
+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;
+
+// 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?: 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 }];
+ let status = opts?.status ?? [statusEntry()];
+ const onSaveLimit = opts?.onSaveLimit;
+ const saveLimitError = opts?.saveLimitError;
+
+ 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> => {
+ calls.deletes.push(providerId);
+ limits = limits.filter((l) => l.providerId !== providerId);
+ return { ok: true, providerId };
+ },
+ loadStatus: async (): Promise<ConcurrencyStatusResult> => {
+ 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 list on mount", async () => {
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The limits summary + the row's remove control (unique to the limits list).
+ expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument();
+ expect(await screen.findByLabelText("Remove concurrency limit for umans")).toBeVisible();
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(1);
+ expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1);
+ });
+
+ it("renders the per-provider cooldown input seeded from the live status", async () => {
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The saved row's cooldown input (in the same row as the limit) is seeded 350.
+ const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans");
+ expect((cooldownInput as HTMLInputElement).value).toBe("350");
+ });
+
+ it("renders a status line (in-flight count left + badge right) below the edit line", async () => {
+ // Default status: limit 4, inFlight 2, queued 1, not paused → Active, "2/4".
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText("2/4 in flight")).toBeVisible();
+ expect(await screen.findByText("Active")).toBeVisible();
+ });
+
+ it("shows the At-capacity badge when in-flight is at the cap with a queue", async () => {
+ const fakes = makeFakes({
+ status: [statusEntry({ inFlight: 4, limit: 4, queued: 3 })],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText("4/4 in flight")).toBeVisible();
+ expect(await screen.findByText("At capacity")).toBeVisible();
+ });
+
+ it("shows the Idle badge when no slots are in flight", async () => {
+ const fakes = makeFakes({
+ status: [statusEntry({ inFlight: 0, limit: 4, queued: 0 })],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText("0/4 in flight")).toBeVisible();
+ expect(await screen.findByText("Idle")).toBeVisible();
+ });
+
+ 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 button is plain-text (no spinner).
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/1 limit configured/);
+
+ const limitsRefresh = screen.getByLabelText("Refresh concurrency limits");
+ expect(limitsRefresh).toHaveTextContent("Refresh");
+ expect(limitsRefresh.querySelector(".loading-spinner")).toBeNull();
+ expect(limitsRefresh).not.toBeDisabled();
+
+ // A manual refresh stays silent too (no spinner appears).
+ await fakes.loadStatus();
+ expect(limitsRefresh.querySelector(".loading-spinner")).toBeNull();
+ });
+
+ it("shows an empty state + Add button when no limits are configured", async () => {
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText(/No limits configured/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible();
+ // No provider dropdown until Add is clicked.
+ expect(screen.queryByLabelText("Provider")).toBeNull();
+ });
+
+ it("reveals a new item row (dropdown + limit + cooldown + Set + ✕) when Add is clicked", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ // The new-item row appears with a provider dropdown (auto-selected first),
+ // a limit input, a cooldown input (seeded with the default 350), Set + ✕.
+ const providerSelect = screen.getByLabelText("Provider");
+ expect((providerSelect as HTMLSelectElement).value).not.toBe("");
+ expect(screen.getByPlaceholderText("4")).toBeVisible();
+ const cooldownInput = screen.getByLabelText("New release cooldown (ms)");
+ expect((cooldownInput as HTMLInputElement).value).toBe("350");
+ expect(screen.getByRole("button", { name: "Set" })).toBeVisible();
+ expect(screen.getByRole("button", { name: "Cancel add" })).toBeVisible();
+ });
+
+ it("adds a provider limit via the new-item row Set (calls saveLimit + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ const providerSelect = screen.getByLabelText("Provider");
+ // Choose "anthropic" from the dropdown (the list is auto-selected first).
+ await user.selectOptions(providerSelect, "anthropic");
+ await user.type(screen.getByPlaceholderText("4"), "8");
+ await user.click(screen.getByRole("button", { name: "Set" }));
+
+ expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]);
+ // The cooldown was left at the default (350) → no extra cooldown PUT fired.
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ // After save the component reloads the limits list (now showing the row).
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2);
+ expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument();
+ expect(await screen.findByLabelText("Remove concurrency limit for anthropic")).toBeVisible();
+ // The add row closed back to the Add button.
+ expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible();
+ });
+
+ it("sends a cooldown PUT when the new item's cooldown is moved off the default", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ await user.selectOptions(screen.getByLabelText("Provider"), "anthropic");
+ await user.type(screen.getByPlaceholderText("4"), "8");
+ const cooldownInput = screen.getByLabelText("New release cooldown (ms)");
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "500");
+ await user.click(screen.getByRole("button", { name: "Set" }));
+
+ expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]);
+ expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "anthropic", cooldownMs: 500 }]);
+ });
+
+ it("disables Set when the limit is empty/invalid (provider is auto-selected)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ const providerSelect = screen.getByLabelText("Provider");
+ // A provider is auto-selected from the dropdown.
+ expect((providerSelect as HTMLSelectElement).value).not.toBe("");
+ const setBtn = screen.getByRole("button", { name: "Set" });
+ expect(setBtn).toBeDisabled(); // no limit entered yet
+
+ // An invalid (non-numeric) limit keeps Set disabled.
+ await user.type(screen.getByPlaceholderText("4"), "abc");
+ expect(setBtn).toBeDisabled();
+
+ // A valid positive-integer limit enables Set.
+ const limitInput = screen.getByPlaceholderText("4");
+ await user.clear(limitInput);
+ await user.type(limitInput, "5");
+ expect(setBtn).toBeEnabled();
+ });
+
+ it("shows no-providers + disables the dropdown when there are no models", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, {
+ props: { ...props(fakes), models: [] as unknown as readonly string[] },
+ });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ const providerSelect = screen.getByLabelText("Provider");
+ expect(providerSelect).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+ });
+
+ it("cancels the new-item row (✕) without saving", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+ await user.type(screen.getByPlaceholderText("4"), "8");
+ await user.click(screen.getByRole("button", { name: "Cancel add" }));
+
+ // The row collapses back to the Add button; nothing was saved.
+ expect(screen.queryByLabelText("Provider")).toBeNull();
+ expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible();
+ expect(fakes.calls.saves).toHaveLength(0);
+ });
+
+ it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // Wait for the limits to load (unique summary) before interacting.
+ await screen.findByText(/1 limit configured/);
+ await user.click(screen.getByLabelText("Remove concurrency limit for umans"));
+
+ expect(fakes.calls.deletes).toEqual(["umans"]);
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2);
+ });
+
+ it("surfaces a load error from the limits endpoint", async () => {
+ const failing = {
+ models: MODELS as unknown as readonly string[],
+ loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({
+ ok: false,
+ error: "Concurrency service not available",
+ }),
+ 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 limits list
+ // now reflects the restored limit (the row's limit input re-seeds to 4).
+ const limitInput = await screen.findByLabelText("Concurrency limit for umans");
+ expect((limitInput as HTMLInputElement).value).toBe("4");
+ 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/ }));
+ const limitInput = await screen.findByLabelText("Concurrency limit for umans");
+ expect((limitInput as HTMLInputElement).value).toBe("4");
+ expect(screen.queryByTestId("restore-error-umans")).toBeNull();
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ });
+
+ it("edits the per-provider cooldown in the limit row (PUT /concurrency/cooldown + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // Wait for the saved row + 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: "Set concurrency for umans" }));
+
+ // The cooldown PUT fired with the new value.
+ expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "umans", cooldownMs: 500 }]);
+ // The limit was NOT re-saved (unchanged) — only the cooldown PUT fired.
+ expect(fakes.calls.saves).toHaveLength(0);
+ });
+
+ it("edits the per-provider limit in the row (PUT /concurrency/limits + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const limitInput = await screen.findByLabelText("Concurrency limit for umans");
+ expect((limitInput as HTMLInputElement).value).toBe("4");
+
+ await user.clear(limitInput);
+ await user.type(limitInput, "8");
+ await user.click(screen.getByRole("button", { name: "Set concurrency for umans" }));
+
+ expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 8 }]);
+ // Cooldown unchanged → no cooldown PUT.
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ });
+
+ it("rejects a negative cooldown input (Set 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: "Set concurrency for umans" })).toBeEnabled();
+
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "-5");
+ expect(screen.getByRole("button", { name: "Set concurrency for umans" })).toBeDisabled();
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ });
+});