summaryrefslogtreecommitdiffhomepage
path: root/src/features/concurrency/logic/view-model.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 13:18:25 +0900
committerAdam Malczewski <[email protected]>2026-06-28 14:41:47 +0900
commit0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3 (patch)
treeb198da2719987ffd58e9c2633fc36684cf2ad316 /src/features/concurrency/logic/view-model.test.ts
parenta59200e786f7d97d7ba5b9cd2bee9ffef531dac2 (diff)
downloaddispatch-web-0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3.tar.gz
dispatch-web-0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3.zip
feat(concurrency): configurable cooldown + adaptive-headroom auto-reduce banner
Consumes the backend's concurrency-fixes (commit 2d27666) — additive to [email protected], NO version bump. ConcurrencyStatusEntry gains cooldownMs / autoReduced / autoReducedFrom? / notice?; new ConcurrencyCooldownResponse / SetConcurrencyCooldownRequest + GET/PUT /concurrency/cooldown/:providerId. FE: - Pure core (logic/view-model.ts): DEFAULT_COOLDOWN_MS; parseCooldownInput (non-negative int — 0 is valid, unlike the limit); normalizeCooldown; cooldownLabel ("350ms"/"1.2s"/"0ms (off)"); viewConcurrencyStatus extended (cooldown + autoReduce fields; auto-reduce → warning badge, not busy); viewAutoReduce/autoReduceNotices (banner view — prefers backend notice, synthesizes a fallback); summarizeStatus "N auto-reduced" fragment; normalizeConcurrencyStatus coerces new fields (immutable readonly build; autoReducedFrom/notice only when autoReduced===true); normalizeConcurrencyCooldown. - Types (logic/types.ts): re-export the 2 new contract types + ConcurrencyCooldownResult + Get/SaveConcurrencyCooldown ports. - UI: ConcurrencyCooldownRow.svelte (inline-edit cooldown + Save → PUT, seeded via the ChatLimitField pattern); AutoReduceBanner.svelte (dismissible banner — backend notice + "Was N, now M." + "Restore to N"); ConcurrencyView renders the cooldown per status card + the banner section. The banner persists while autoReduced===true, is dismissible, and clears automatically once a poll shows autoReduced===false after a restore PUT. - Store (store.svelte.ts): getConcurrencyCooldown + setConcurrencyCooldown (surface 400/404/503 as ok:false; normalize at the seam) + interface decls. - App.svelte: saveConcurrencyCooldown adapter → ConcurrencyView. - Tests: +47 (view-model cooldown/auto-reduce/normalizers; component banner render, cooldown PUT, restore clears banner, dismiss; store cooldown GET/PUT). Re-synced the file: dep (bun install) + re-mirrored .dispatch/transport-contract. reference.md. typecheck 0/0, 1048 tests green (run twice), biome clean, build OK. Worktree env: an untracked dispatch-backend → backend symlink was created in the worktree parent so the canonical file:../dispatch-backend/... paths resolve (NOT committed — per the §2d/§2j worktree convention).
Diffstat (limited to 'src/features/concurrency/logic/view-model.test.ts')
-rw-r--r--src/features/concurrency/logic/view-model.test.ts232
1 files changed, 230 insertions, 2 deletions
diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts
index 82b72b0..c284ad0 100644
--- a/src/features/concurrency/logic/view-model.test.ts
+++ b/src/features/concurrency/logic/view-model.test.ts
@@ -1,16 +1,22 @@
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,
summarizeLimits,
summarizeStatus,
+ viewAutoReduce,
viewConcurrencyLimit,
viewConcurrencyLimits,
viewConcurrencyStatus,
@@ -23,6 +29,8 @@ const status = (over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEn
inFlight: 2,
queued: 0,
paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
...over,
});
@@ -46,6 +54,41 @@ describe("parseLimitInput", () => {
});
});
+// ── 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", () => {
@@ -178,6 +221,8 @@ describe("viewConcurrencyStatus", () => {
inFlight: Number.NaN,
queued: "oops" as unknown as number,
paused: false,
+ cooldownMs: Number.NaN,
+ autoReduced: false,
},
0,
);
@@ -185,6 +230,7 @@ describe("viewConcurrencyStatus", () => {
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", () => {
@@ -194,6 +240,81 @@ describe("viewConcurrencyStatus", () => {
);
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);
+ });
+});
+
+// ── 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 ───────────────────────────────────────────────────────
@@ -273,6 +394,16 @@ describe("summarizeStatus", () => {
"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 ───────────────────────────────────────────────────
@@ -359,6 +490,8 @@ describe("normalizeConcurrencyStatus", () => {
inFlight: 2,
queued: 1,
paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
});
expect(first !== undefined && !("pausedUntil" in first)).toBe(true);
expect(second).toEqual({
@@ -368,6 +501,8 @@ describe("normalizeConcurrencyStatus", () => {
queued: 3,
paused: true,
pausedUntil: now,
+ cooldownMs: 350,
+ autoReduced: false,
});
});
@@ -388,8 +523,24 @@ describe("normalizeConcurrencyStatus", () => {
],
});
expect(providers).toEqual([
- { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
- { providerId: "x", limit: 1, inFlight: 0, queued: 0, paused: false },
+ {
+ 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,
+ },
]);
});
@@ -402,4 +553,81 @@ describe("normalizeConcurrencyStatus", () => {
});
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();
+ });
});