diff options
25 files changed, 2168 insertions, 155 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 6a5a6b3..0582d94 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -8,6 +8,21 @@ > **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers + provider concurrency). Regenerate whenever > it changes. > +> **2026-06-27 update (concurrency-fixes — ADDITIVE, NO version bump):** the provider concurrency surface gains +> (a) a configurable + persisted per-provider release COOLDOWN, and (b) adaptive headroom. `ConcurrencyStatusEntry` +> gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in ms, default 350; a recycled slot is +> held this long before the next waiter is admitted), `autoReduced: boolean` (REQUIRED — true when the limit was auto-reduced +> by 1 after a 429, one-way + persisted; the FE renders a visible banner), `autoReducedFrom?: number` (present only when +> `autoReduced===true` — the original limit before reduction), and `notice?: string` (present only when `autoReduced===true` — +> a human-readable banner message). The banner is DISMISSIBLE / persists while `autoReduced===true`; it clears when the user +> restores the limit via `PUT /concurrency/limits/:providerId` (a manual PUT clears `autoReduced` server-side). NEW cooldown +> endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) — 404 when +> the provider has no concurrency config at all (no limit, no cooldown), 503 when the extension isn't loaded; +> `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — must be a non-negative integer, +> 0 = no cooldown / instant re-admission) → `ConcurrencyCooldownResponse` — 400 on an invalid body, 503 when not loaded. +> Persists + applies immediately to subsequently recycled slots. Also (backend-only, no FE surface): a usage gate polls upstream +> `concurrent_sessions` before admitting a queued agent. See `backend-handoff.md` §2j-update-3. +> > **2026-06-26 delta (provider concurrency — `[email protected]` bump):** adds the > per-provider concurrency-limits API types: `ConcurrencyLimitsResponse` (`GET /concurrency/limits`), > `SetConcurrencyLimitRequest` + `ConcurrencyLimitResponse` (`GET`/`PUT /concurrency/limits/:providerId`), @@ -1062,6 +1077,17 @@ export interface ConcurrencyLimitResponse { * - `queued`: how many agents are waiting for a slot. * - `paused`: whether the queue is paused due to a 429 backoff. * - `pausedUntil`: when the pause expires (epoch-ms), present only when paused. + * - `cooldownMs`: the per-slot release cooldown (ms). A recycled slot is held + * this long before the next waiter is admitted — covers the upstream + * provider's accounting lag. Configurable + persisted per provider. + * - `autoReduced`: whether the limit was auto-reduced by 1 after a 429 + * (adaptive headroom, one-way, persisted). The user restores the limit + * manually via `PUT /concurrency/limits/:providerId`, which clears the flag. + * When `true`, the frontend renders a visible notice/banner. + * - `autoReducedFrom`: the original limit before auto-reduction (present only + * when `autoReduced` is true). + * - `notice`: a human-readable notice string for the frontend to render as a + * banner when the limit was auto-reduced (present only when `autoReduced`). */ export interface ConcurrencyStatusEntry { readonly providerId: string; @@ -1070,6 +1096,10 @@ export interface ConcurrencyStatusEntry { readonly queued: number; readonly paused: boolean; readonly pausedUntil?: number; + readonly cooldownMs: number; + readonly autoReduced: boolean; + readonly autoReducedFrom?: number; + readonly notice?: string; } /** * Response of `GET /concurrency/status` — live status for every provider with a @@ -1078,5 +1108,28 @@ export interface ConcurrencyStatusEntry { export interface ConcurrencyStatusResponse { readonly providers: readonly ConcurrencyStatusEntry[]; } + +// ─── Provider concurrency cooldown ──────────────────────────────────────────── + +/** + * Response of `GET /concurrency/cooldown/:providerId` — the per-slot release + * cooldown (ms) for a provider. A recycled slot is held this long before the + * next waiter is admitted, covering the upstream provider's accounting lag. + * When no cooldown was explicitly set, the server default (350ms) is returned. + */ +export interface ConcurrencyCooldownResponse { + readonly providerId: string; + readonly cooldownMs: number; +} + +/** + * Body of `PUT /concurrency/cooldown/:providerId` — set the release cooldown + * (ms) for a provider. `cooldownMs` must be a non-negative integer (0 = no + * cooldown, instant re-admission). The value is persisted and applied to + * subsequently recycled slots. + */ +export interface SetConcurrencyCooldownRequest { + readonly cooldownMs: number; +} ``` diff --git a/backend-handoff.md b/backend-handoff.md index 3f4642b..4e3c6df 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,6 +5,22 @@ > **From:** dispatch-web orchestrator · **To:** `../backend` orchestrator · **Courier:** the user. > `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here. +_Last updated: 2026-06-27 (FE-only slice: **workspace-active indicator** — loading-dots on +workspace cards when a workspace has ≥1 active/queued conversation. New `AppStore.workspaceHasActiveConversations(workspaceId)` derives from the existing open-tab set (every active/queued +conversation has an open tab stamped with its `workspaceId`) × the backend lifecycle statuses; a +`hasActive?: (workspaceId: string) => boolean` port on `WorkspacesHome`/`WorkspaceCard` is wired at +`src/App.svelte`. DaisyUI `loading-dots` (same as the tab/composer active indicator). **No backend / +contract change** — no re-pin/re-mirror. typecheck 0/0, 1029 tests green (+10), biome clean, build OK.)_ +_Last updated: 2026-06-27 (§2j-update-3 — concurrency-fixes: configurable + persisted per-provider release +cooldown, adaptive headroom auto-reduce banner, usage gate. ADDITIVE to `[email protected]`, NO version +bump — `ConcurrencyStatusEntry` gained `cooldownMs`/`autoReduced`/`autoReducedFrom?`/`notice?`; NEW +`ConcurrencyCooldownResponse`/`SetConcurrencyCooldownRequest` + `GET`/`PUT /concurrency/cooldown/:providerId`. +FE re-synced the `file:` dep + re-mirrored `.dispatch/transport-contract.reference.md`; built the cooldown +view-model + inline-edit row, a dismissible auto-reduce banner with "Restore to N", store `getConcurrencyCooldown`/ +`setConcurrencyCooldown`, + 49 new tests. typecheck 0/0, 1050 tests green (run TWICE), biome clean, build OK. +Worktree env note: an untracked `dispatch-backend → backend` symlink was created in the worktree parent so the +canonical `file:../dispatch-backend/...` paths resolve — NOT committed.) Prior: §2j (vision consult_vision + image +storage)._ _Last updated: 2026-06-27 (workspace starring — backend `feature/workspace-star` shipped `Workspace.starred: boolean` (additive to `[email protected]`, NO version bump) + `PUT`/`DELETE /workspaces/:id/star` endpoints (no body; create-on-miss; return the updated `Workspace`). FE consumed: `adapter/http` `star`/`unstar`, pure `sortWorkspaces`/`applyStarred`, @@ -58,6 +74,8 @@ WS `conversation.statusChanged` (broadcast: lifecycle status change — `active` `GET /concurrency/limits` · `GET`/`PUT`/`DELETE /concurrency/limits/:providerId` · `GET /concurrency/status` (per-provider in-flight caps + oldest-agent-first queueing + 429-pause backoff; the `concurrency` extension — when not loaded the list + status endpoints return empty arrays, the single/PUT/DELETE return `503`). +`GET`/`PUT /concurrency/cooldown/:providerId` (per-slot release cooldown — configurable + persisted; 0 = no cooldown; +`GET` 404 when the provider has no concurrency config at all, `PUT` 400 on a non-negative-int body — see §2j-update-3). Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-contract}.reference.md` (regenerate on any contract bump; all current as of `[email protected]` / @@ -1064,6 +1082,118 @@ conversation → confirm the persisted image renders from the `/images/…` endp --- +## 2k. Step-level context-window usage (progressive) → **FE BUILT; no backend change** + +The context-window usage indicator at the bottom of the screen (Composer status +bar) now updates **after each step** during a multi-step turn, instead of only +when the turn seals. Pure FE change — consumes wire events the backend ALREADY +sends (`usage` per step + `step-complete` + `done.contextSize`); no contract +change, re-pin, or re-mirror needed. + +- `selectCurrentContextSize` (`core/metrics/reducer.ts`) now, for an IN-FLIGHT + (not-done) turn, returns the most recent step WITH USAGE's + `inputTokens + outputTokens` as the live context occupancy. Per the wire + contract each step's input already includes all prior context (the prompt is + re-prefilled every step), so the last step's input+output is the true occupancy + — the same definition `TurnDoneEvent.contextSize` stamps at turn end. +- A finalized turn (`done` / durable) still wins with its authoritative + `contextSize`; durable still wins over live for a shared `turnId`. An in-flight + turn with no step usage yet falls back to the next older finalized turn (never + `0`). +- New helper `liveTurnContextSize`; updated doc on `ChatStore.currentContextSize` + + the Composer `contextSize` prop. 7 new reducer tests (35 total), 1026 green. + +### FE summary (this slice) +No backend ask. The backend already emits per-step `usage` (token counts, may +arrive mid-stream) and `step-complete` (timing) joined by `stepId`, plus +`done.contextSize` (final step's input+output) — the FE just wasn't reading the +per-step usage for the live indicator. Now it does. +## 2j-update-3. Concurrency-fixes (cooldown + adaptive headroom + usage gate) → **CONSUMED ✅ (backend shipped; FE built + verified)** + +A follow-up to §2j. The backend's per-provider concurrency surface gained (a) a configurable + persisted +per-provider release **cooldown**, (b) **adaptive headroom** — a 429 auto-reduces the provider's limit by 1 +(one-way, persisted) and the FE renders a visible banner, and (c) a **usage gate** (backend polls upstream +`concurrent_sessions` before admitting a queued agent — no FE surface). The signal rides on +`GET /concurrency/status` (no new WS push). **Additive to `[email protected]`, NO version bump** +(the FE's `file:` dep picks the new types up via re-sync, no re-pin needed). Backend commit +`feature/concurrency-fixes` `2d27666` ("usage-gate + adaptive headroom + configurable cooldown"). + +**Contract changes (re-mirrored in `.dispatch/transport-contract.reference.md`):** +- `ConcurrencyStatusEntry` gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in + ms, default 350; a recycled slot is held this long before the next waiter is admitted, covering the upstream + provider's accounting lag — configurable + persisted), `autoReduced: boolean` (REQUIRED — true when the limit + was auto-reduced by 1 after a 429, one-way + persisted; the FE renders a banner), `autoReducedFrom?: number` + (present only when `autoReduced===true` — the original limit before reduction), and `notice?: string` + (present only when `autoReduced===true` — a human-readable banner message). +- NEW cooldown types: `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) + + `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — non-negative integer, 0 = no cooldown / instant re-admission). +- NEW endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (404 when the provider + has no concurrency config at all — no limit AND no cooldown; 503 when the extension isn't loaded); + `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` → `ConcurrencyCooldownResponse` + (400 on an invalid body; 503 when not loaded). Persists + applies immediately to subsequently recycled slots. +- A manual `PUT /concurrency/limits/:providerId` CLEARS `autoReduced` server-side (the restore path). + +**FE (DONE + verified):** +- **Pure core (`logic/view-model.ts`):** `DEFAULT_COOLDOWN_MS = 350`; `parseCooldownInput` (non-negative integer — + unlike the limit, **0 is valid**); `normalizeCooldown` (defensive default 350 on garbage); + `cooldownLabel` ("350ms" / "1.2s" / "0ms (off)"); `viewConcurrencyStatus` extended to carry `cooldownMs` + + `cooldownLabel` + `autoReduced` + `autoReducedFrom` (auto-reduce → `warning` badge but NOT `busy` — a reduced + limit still admits agents); `viewAutoReduce`/`autoReduceNotices` (banner view — prefers the backend `notice` + verbatim, synthesizes a fallback when absent, `fromLimit` = `autoReducedFrom` for "Restore to N"); + `summarizeStatus` gained an "N auto-reduced" fragment; `normalizeConcurrencyStatus` coerces the new fields + (builds the readonly entry immutably — `autoReducedFrom`/`notice` only when `autoReduced===true`, dropped when + false even if present in the JSON); `normalizeConcurrencyCooldown` (network-seam coercion). +- **Types (`logic/types.ts`):** re-exports the 2 new contract types + `ConcurrencyCooldownResult` + + `GetConcurrencyCooldown`/`SaveConcurrencyCooldown` ports. +- **UI:** new `ui/ConcurrencyCooldownRow.svelte` (per-provider inline-edit cooldown input + Save → PUT, seeded via + the ChatLimitField pattern so a status-poll refresh re-syncs without clobbering an in-flight edit; "Saved." + confirmation); new `ui/AutoReduceBanner.svelte` (the dismissible banner — backend `notice` + "Was N, now M." + + "Restore to N" PUT button + ✕ dismiss). `ConcurrencyView.svelte`: cooldown label per status card + + `ConcurrencyCooldownRow`; an auto-reduce banner section at the top of the panel. The banner is DISMISSIBLE + + persists while `autoReduced===true`: a dismissed provider stays hidden while still auto-reduced, and is + un-dismissed the moment a poll shows it restored (a `$effect` reconciles the dismissed set against the live + auto-reduced providers). "Restore to N" PUTs the limit back to `autoReducedFrom` via `saveLimit` → the next + status poll shows `autoReduced===false` → the banner drops automatically. +- **Store (`store.svelte.ts`):** `getConcurrencyCooldown` (`GET .../cooldown/:id`) + `setConcurrencyCooldown` + (`PUT .../cooldown/:id` ← `{ cooldownMs }`) — both surface 400/404/503 as `ok:false` with the backend's `error` + string + normalize the body at the seam. Interface declarations added. (Mirrors the §2j "FE implements all API + client functions but the UI uses a subset" note: the UI seeds cooldown from the live status `cooldownMs`, so + `getConcurrencyCooldown` is an API-client function for completeness/future use — `saveCooldown` is the wired one.) +- **Wired in `App.svelte`:** `saveConcurrencyCooldown` adapter → `ConcurrencyView`'s `saveCooldown` prop. +- **Tests:** +47 (view-model: `parseCooldownInput`/`cooldownLabel`/`normalizeConcurrencyCooldown`/`viewAutoReduce`/ + `autoReduceNotices`/`summarizeStatus` auto-reduced/`normalizeConcurrencyStatus` new-field coercion; component: + cooldown label render, cooldown PUT flow, negative-input rejection, auto-reduce banner render, restore clears + banner, dismiss-while-auto-reduced; store: `getCooldown` load/404, `setCooldown` PUT echo + 400). + +**Verification:** `svelte-check` 0/0; vitest **1050/1050** (run TWICE — no cross-test pollution; the polling +intervals are per-component, cleaned up on unmount by `@testing-library/svelte`'s auto-cleanup), biome clean, +`vite build` succeeds (the one CSS warning is PRE-EXISTING — `[file:path]`/`[heartbeat:elapsed]` attribute +selectors, unrelated). **Live probe NOT run** (the backend is the user's process; never booted headless). To +confirm end-to-end: start the backend with the `concurrency` extension loaded, open the Concurrency sidebar view, +confirm the cooldown label + edit field per provider; set a cooldown → Save → confirm it persists on reload; +trigger a 429 on a limited provider → confirm the auto-reduce banner appears (with `notice` + "Was N, now M.") → +click "Restore to N" → confirm the banner drops on the next poll. + +**Post-review fixes (folded into the same commit):** a Kimi review flagged a MEDIUM bug + 2 LOW issues, all fixed: +- **MEDIUM — restore failure gave no inline feedback:** `restoreLimit` now returns a `RestoreOutcome` + (`{ ok: true } | { ok: false; error }`) and `AutoReduceBanner.handleRestore` shows the error INLINE next to the + restore button (cleared on retry) instead of silently re-enabling the button. New `RestoreOutcome` type in + `logic/types.ts` + exported. 2 new tests (inline error on failure; error clears on a retry that succeeds). +- **LOW — a11y:** the "Restore to N" text now stays visible while loading (spinner prepended, not replacing the + text), so the button keeps its accessible name during the PUT (a spinner-only button loses its name for SR users). +- **LOW — dismissed-banner persistence:** confirmed INTENTIONAL (not a bug). The dismissed set is component-local + (resets on remount): `autoReduced` is a REAL persisted degraded state, so re-showing the banner on a fresh mount + (sidebar view switch / reload) reminds the user; persisting dismissal in localStorage would risk HIDING an ongoing + degradation (a footgun), and AGENTS.md forbids module-global ambient state. Documented in a code comment. + +**Worktree environment note (same as §2d/§2j):** this worktree lays the repos out as +`…/worktrees/concurrency-fixes/{backend,frontend}`, but `package.json`'s canonical `file:` paths point at +`../dispatch-backend/...` (kept canonical — no worktree hack committed). An UNTRACKED symlink +`dispatch-backend → backend` was created in the worktree parent, then `bun install` re-synced +`node_modules/@dispatch/*` to pick up the additive `[email protected]` types. + +--- + ## 3. Likely NEXT backend asks (heads-up, not yet requested) - **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns diff --git a/src/App.svelte b/src/App.svelte index 713c844..27c9271 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -68,7 +68,12 @@ </script> {#if route.kind === "home"} - <WorkspacesHome store={workspaceStore} onNavigate={navigate} computers={store.computers} /> + <WorkspacesHome + store={workspaceStore} + onNavigate={navigate} + computers={store.computers} + hasActive={(id) => store.workspaceHasActiveConversations(id)} + /> {:else} <App {store} onNavigate={navigate} /> {/if} diff --git a/src/app/App.svelte b/src/app/App.svelte index 59f949c..b2cfd2d 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -76,6 +76,7 @@ type DeleteConcurrencyLimit, type LoadConcurrencyLimits, type LoadConcurrencyStatus, + type SaveConcurrencyCooldown, type SaveConcurrencyLimit, } from "../features/concurrency"; import type { ChatStore } from "../features/chat"; @@ -491,6 +492,8 @@ const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) => store.deleteConcurrencyLimit(providerId); const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus(); + const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) => + store.setConcurrencyCooldown(providerId, cooldownMs); </script> <main class="relative flex h-screen overflow-hidden"> @@ -501,7 +504,7 @@ the top row now shows the active tab's title on the left (or "New Tab" for an unstarted draft), with the build version + sidebar toggle on the right. --> - <div class="flex items-center justify-between gap-2 px-2"> + <div class="flex items-center justify-between gap-2 px-2 py-2"> <span class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70" data-testid="top-bar-title" @@ -841,6 +844,7 @@ saveLimit={saveConcurrencyLimit} deleteLimit={deleteConcurrencyLimit} loadStatus={loadConcurrencyStatus} + saveCooldown={saveConcurrencyCooldown} /> {/if} {/snippet} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index ead27a6..22b0a25 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -55,12 +55,14 @@ import { import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; import { createChatStore } from "../features/chat"; import type { + ConcurrencyCooldownResult, ConcurrencyDeleteResult, ConcurrencyLimitResult, ConcurrencyLimitsResult, ConcurrencyStatusResult, } from "../features/concurrency"; import { + normalizeConcurrencyCooldown, normalizeConcurrencyLimit, normalizeConcurrencyLimits, normalizeConcurrencyStatus, @@ -351,6 +353,16 @@ export interface AppStore { */ conversationStatus(conversationId: string): ConversationStatus | undefined; /** + * Whether at least one conversation in the given workspace is currently + * active or queued (generating / waiting for a concurrency slot) — drives + * the loading-dots indicator on workspace cards. Backed by a once-derived + * `activeWorkspaces` set (the open-tab set × the backend lifecycle statuses) + * so this is an O(1) lookup, not a per-card scan of the full tab list. + * Reactive: the set is a `$derived`, so a Svelte template expression calling + * this re-runs when the tab set or status map changes. + */ + workspaceHasActiveConversations(workspaceId: string): boolean; + /** * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to * localStorage and propagates to every live chat store (trim if lower, * deferred via the unload gate while a reader is scrolled up; no-op if @@ -442,11 +454,29 @@ export interface AppStore { /** * Fetch live concurrency status for every provider with a configured limit * (`GET /concurrency/status`): in-flight slots held, agents queued, and a paused - * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Returns + * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Each + * entry also carries the per-slot release `cooldownMs` + an `autoReduced` flag + * (true when a 429 auto-reduced the limit by 1; the FE renders a banner). Returns * an empty list when the extension isn't loaded (`{ providers: [] }`). */ concurrencyStatus(): Promise<ConcurrencyStatusResult>; /** + * Fetch the per-slot release cooldown (ms) for one provider + * (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at + * all) and `503` (extension not loaded) both surface as `ok: false`. + */ + getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult>; + /** + * Set the per-slot release cooldown (ms) for one provider + * (`PUT /concurrency/cooldown/:providerId`, body `{ cooldownMs }`). `cooldownMs` + * must be a non-negative integer (0 = no cooldown / instant re-admission); an + * invalid body is `400`. Persists + applies to subsequently recycled slots. + */ + setConcurrencyCooldown( + providerId: string, + cooldownMs: number, + ): Promise<ConcurrencyCooldownResult>; + /** * A critical error that blocks normal operation (e.g. the cross-device tab * restore fetch failed). When non-null, a full-screen modal is shown with the * error details. Cleared by `clearFatalError` (the modal's dismiss button). @@ -966,6 +996,22 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // fetched on connect). Keyed by conversationId. let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map()); + // The set of workspaces with ≥1 active/queued conversation, derived ONCE + // (not recomputed per card). Every active/queued conversation has an open + // tab stamped with its workspace, so the tabs are the conversation→workspace + // map; cross-reference with the lifecycle statuses. `$derived` recomputes + // lazily when the tab set or status map changes, so each + // `workspaceHasActiveConversations` call is an O(1) lookup instead of a scan + // of the full tab list per card. + const activeWorkspaces = $derived.by(() => { + const out = new Set<string>(); + for (const tab of tabsStore.tabs) { + const status = conversationStatuses.get(tab.conversationId); + if (status === "active" || status === "queued") out.add(tab.workspaceId); + } + return out; + }); + /** * Fetch `GET /conversations?status=active,idle` on connect to restore the * tab bar across devices. Merges: opens tabs for conversations not already @@ -1255,6 +1301,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { conversationStatus(conversationId: string): ConversationStatus | undefined { return conversationStatuses.get(conversationId); }, + workspaceHasActiveConversations(workspaceId: string): boolean { + // O(1) lookup into the once-derived `activeWorkspaces` set; false when the + // workspace has no active/queued conversation (or none at all). + return activeWorkspaces.has(workspaceId); + }, get currentConversationId(): string { return workspaceConversationId(); }, @@ -1958,6 +2009,64 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, + async getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult> { + try { + const res = await fetchImpl( + `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Get concurrency cooldown failed (HTTP ${res.status})`, + }; + } + const data = normalizeConcurrencyCooldown(await res.json()); + if (data === null) { + return { ok: false, error: "Malformed concurrency cooldown response" }; + } + return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Get concurrency cooldown request failed", + }; + } + }, + + async setConcurrencyCooldown( + providerId: string, + cooldownMs: number, + ): Promise<ConcurrencyCooldownResult> { + try { + const res = await fetchImpl( + `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ cooldownMs }), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set concurrency cooldown failed (HTTP ${res.status})`, + }; + } + const data = normalizeConcurrencyCooldown(await res.json()); + if (data === null) { + return { ok: false, error: "Malformed concurrency cooldown response" }; + } + return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set concurrency cooldown request failed", + }; + } + }, + async loadSystemPrompt(): Promise<SystemPromptLoadResult> { try { const res = await fetchImpl(`${httpBase}/system-prompt`); diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 2cf473c..a945ea7 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -553,13 +553,17 @@ describe("createAppStore", () => { event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" }, }); - await new Promise((r) => setTimeout(r, 50)); - - expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); - - await new Promise((r) => setTimeout(r, 50)); + // `turn-sealed` triggers an async `syncTail` (cache.sinceSeq → historySync + // → cache.commit → applyHistory). Poll for the side-effect rather than + // guessing a fixed delay — under suite load a fixed `setTimeout` raced the + // fetch chain and flaked here. + await vi.waitFor(() => { + expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); + }); - expect(store.activeChat.chunks.length).toBeGreaterThan(0); + await vi.waitFor(() => { + expect(store.activeChat.chunks.length).toBeGreaterThan(0); + }); store.dispose(); }); @@ -1458,6 +1462,20 @@ describe("createAppStore", () => { if (url.includes("/concurrency/limits/") && method === "DELETE") { return new Response(JSON.stringify({ ok: true, providerId: "umans" }), { status: 200 }); } + if (url.includes("/concurrency/cooldown/") && method === "GET") { + return new Response(JSON.stringify({ providerId: "umans", cooldownMs: 350 }), { + status: 200, + }); + } + if (url.includes("/concurrency/cooldown/") && method === "PUT") { + const seg = url.slice( + url.lastIndexOf("/concurrency/cooldown/") + "/concurrency/cooldown/".length, + ); + const body = init?.body ? JSON.parse(init.body as string) : {}; + return new Response(JSON.stringify({ providerId: seg, cooldownMs: body.cooldownMs ?? 0 }), { + status: 200, + }); + } return base(input, init); }; } @@ -1641,11 +1659,15 @@ describe("createAppStore", () => { inFlight: 2, queued: 1, paused: false, + cooldownMs: 350, + autoReduced: false, }); expect(result.providers[1]).toMatchObject({ providerId: "openai-compat", paused: true, pausedUntil: 1_719_408_000_000, + cooldownMs: 350, + autoReduced: false, }); store.dispose(); }); @@ -1663,6 +1685,100 @@ describe("createAppStore", () => { store.dispose(); }); + it("getConcurrencyCooldown loads + coerces the cooldown", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.getConcurrencyCooldown("umans"); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.providerId).toBe("umans"); + expect(result.cooldownMs).toBe(350); + store.dispose(); + }); + + it("getConcurrencyCooldown surfaces a 404 (no concurrency config) as ok:false", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("/concurrency/cooldown/")) { + return new Response( + JSON.stringify({ error: "No concurrency configuration for this provider" }), + { + status: 404, + }, + ); + } + return fakeFetchImpl()(input); + }, + localStorage: createFakeStorage(), + }); + const result = await store.getConcurrencyCooldown("ghost"); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("No concurrency configuration"); + store.dispose(); + }); + + it("setConcurrencyCooldown PUTs { cooldownMs } + returns the echoed value", async () => { + const calls: { url: string; method: string; body: unknown }[] = []; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + calls.push({ url, method, body: init?.body ? JSON.parse(init.body as string) : null }); + if (url.includes("/concurrency/cooldown/") && method === "PUT") { + const body = init?.body ? JSON.parse(init.body as string) : {}; + return new Response( + JSON.stringify({ providerId: "umans", cooldownMs: body.cooldownMs }), + { status: 200 }, + ); + } + return fakeFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.setConcurrencyCooldown("umans", 500); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.cooldownMs).toBe(500); + const cooldownCall = calls.find( + (c) => c.url.includes("/concurrency/cooldown/") && c.method === "PUT", + ); + expect(cooldownCall?.url).toContain("/concurrency/cooldown/umans"); + expect(cooldownCall?.body).toEqual({ cooldownMs: 500 }); + store.dispose(); + }); + + it("setConcurrencyCooldown surfaces a 400 (invalid body) as ok:false", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("/concurrency/cooldown/") && init?.method === "PUT") { + return new Response( + JSON.stringify({ error: "Body must be { cooldownMs: <non-negative integer> }" }), + { status: 400 }, + ); + } + return fakeFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.setConcurrencyCooldown("umans", -1); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("non-negative integer"); + store.dispose(); + }); + // ── Conversation status: `queued` (CR-13 — waiting for a concurrency slot) ──── it("conversation.statusChanged 'queued' sets the status (tab spinner) without opening a duplicate tab", () => { @@ -1730,6 +1846,122 @@ describe("createAppStore", () => { expect(store.tabs.some((t) => t.conversationId === "other-device-conv")).toBe(true); store.dispose(); }); + + // ── workspaceHasActiveConversations (workspace-card active indicator) ───── + + it("workspaceHasActiveConversations is false when no conversation is active", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + store.send("hello"); + const convId = activeConversationId(store); + + // The conversation is freshly created — the backend hasn't reported it as + // active yet, so the workspace has no active conversation. + expect(store.conversationStatus(convId)).toBeUndefined(); + expect(store.workspaceHasActiveConversations("default")).toBe(false); + store.dispose(); + }); + + it("workspaceHasActiveConversations is true when a conversation in the workspace is active", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + store.send("hello"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "active", + workspaceId: "default", + }); + + expect(store.workspaceHasActiveConversations("default")).toBe(true); + store.dispose(); + }); + + it("workspaceHasActiveConversations is true when a conversation is queued (waiting for a slot)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + store.send("hello"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "queued", + workspaceId: "default", + }); + + expect(store.workspaceHasActiveConversations("default")).toBe(true); + store.dispose(); + }); + + it("workspaceHasActiveConversations goes back to false when the conversation goes idle", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + store.send("hello"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "active", + workspaceId: "default", + }); + expect(store.workspaceHasActiveConversations("default")).toBe(true); + + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "idle", + workspaceId: "default", + }); + expect(store.workspaceHasActiveConversations("default")).toBe(false); + store.dispose(); + }); + + it("workspaceHasActiveConversations scopes to the given workspace (ignores other workspaces)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // A cross-device active conversation in workspace "proj-a". + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: "proj-a-conv", + status: "active", + workspaceId: "proj-a", + }); + + // proj-a is active; proj-b is not (no active conversation there). + expect(store.workspaceHasActiveConversations("proj-a")).toBe(true); + expect(store.workspaceHasActiveConversations("proj-b")).toBe(false); + store.dispose(); + }); }); describe("createAppStore — vision settings (global)", () => { diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts index c7c4fbb..97170d0 100644 --- a/src/core/metrics/format.test.ts +++ b/src/core/metrics/format.test.ts @@ -351,8 +351,14 @@ describe("computeContextUsage", () => { expect(u.percent).toBeCloseTo(3.4102, 4); }); - it("treats unknown contextSize as current 0", () => { + it("treats unknown contextSize as current null (never 0)", () => { const u = computeContextUsage(undefined, 1_000_000); + expect(u.current).toBeNull(); + expect(u.percent).toBeNull(); + }); + + it("an explicit 0 context size is a real reported value (current 0)", () => { + const u = computeContextUsage(0, 1_000_000); expect(u.current).toBe(0); expect(u.percent).toBe(0); }); diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts index 56e74e4..894bd54 100644 --- a/src/core/metrics/format.ts +++ b/src/core/metrics/format.ts @@ -45,14 +45,17 @@ export function formatCompactTokens(n: number): string { /** * Context-window occupancy: the current size against a max window limit. * - * `current` is the latest turn's context size (0 when unknown); `max` is the - * model's window limit (or `null` when unknown). `percent` is - * `current / max * 100` clamped to [0, 100], UNROUNDED (the UI picks the - * precision) — so a few-thousand-token context against a 1,000,000 window still - * reads non-zero. `percent` is `null` when `max` is unknown (no bar/denominator). + * `current` is the latest turn's context size, or `null` when unknown (no + * per-step usage reported yet) — NEVER coerced to `0`, so a consumer cannot + * silently render "0 tokens / 1M"; it must branch on `current === null` and show + * a placeholder instead. `max` is the model's window limit (or `null` when + * unknown). `percent` is `current / max * 100` clamped to [0, 100], UNROUNDED + * (the UI picks the precision) — so a few-thousand-token context against a + * 1,000,000 window still reads non-zero. `percent` is `null` when `current` OR + * `max` is unknown (no bar/denominator). */ export interface ContextUsage { - readonly current: number; + readonly current: number | null; readonly max: number | null; readonly percent: number | null; } @@ -61,9 +64,10 @@ export function computeContextUsage( contextSize: number | undefined, contextLimit: number | null | undefined, ): ContextUsage { - const current = contextSize ?? 0; + const current = contextSize ?? null; const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; - const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); + const percent = + current === null || max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); return { current, max, percent }; } diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts index c05ba3b..9c925a3 100644 --- a/src/core/metrics/place.test.ts +++ b/src/core/metrics/place.test.ts @@ -354,6 +354,81 @@ describe("interleaveTurnMetrics", () => { expectGroupAt(rows, 7, g6); }); + it("trimmed leading turns: a mixed tool+text transcript tail-aligns text-only turns to their OWN (newest) entries, not stale trimmed ones", () => { + // A long conversation where the chat limit unloaded the oldest turn (t1). + // Metrics still hold all three turns; the loaded transcript is turns 2-3. + // Turn 2 is a tool turn (matched by stepId); turn 3 is text-only (no + // stepId groups) — the failure case. The text-only turn MUST get its OWN + // entry (t3), NOT the trimmed t1's stale metrics. + const g3 = userGroup(3, "q2"); + const g4 = toolBatchGroup("s2", ["c2"]); + const g5 = assistantGroup(4, "tool-reply"); + const g6 = userGroup(5, "q3"); + const g7 = assistantGroup(6, "text-reply"); + const step1 = makeStep("s1", 11, 1); // t1 (trimmed) + const step2 = makeStep("s2", 22, 2); // t2 (loaded, tool) + const step3 = makeStep("s3", 33, 3); // t3 (loaded, text-only — unanchored) + const entries = [ + makeEntry("t1", 11, 1, [step1]), + makeEntry("t2", 22, 2, [step2]), + makeEntry("t3", 33, 3, [step3]), + ]; + const rows = interleaveTurnMetrics([g3, g4, g5, g6, g7], entries); + + const tmRows = rows.filter( + (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics", + ); + // Two loaded turns → two turn-metrics rows. The trimmed t1 does NOT render. + expect(tmRows).toHaveLength(2); + // CRITICAL: the text-only turn (segment 1) got t3 (its own newest entry), + // not t1 (the stale trimmed one). A misaligned head-align would show t1. + expect(tmRows[1]?.turn.turnId).toBe("t3"); + expect(tmRows[0]?.turn.turnId).toBe("t2"); + // And t1 never appears as a rendered row. + expect(tmRows.some((r) => r.turn.turnId === "t1")).toBe(false); + }); + + it("trimmed turn still counts toward the cumulative 'chat total' on the first visible turn", () => { + // t1 is trimmed (no segment) but finalized; t2 is the loaded visible turn. + // t2's "Chat Total" cumulative must INCLUDE t1's usage (the whole chat), + // even though t1 renders no row of its own. + const g1 = userGroup(2, "q2"); + const g2 = assistantGroup(3, "a2"); + const entries = [ + { + turnId: "t1", + steps: [], + total: { + turnId: "t1", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 }, + steps: [], + }, + }, + { + turnId: "t2", + steps: [], + total: { + turnId: "t2", + usage: { inputTokens: 2000, outputTokens: 20, cacheReadTokens: 1600 }, + steps: [], + }, + }, + ]; + const rows = interleaveTurnMetrics([g1, g2], entries); + const tmRows = rows.filter( + (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics", + ); + // Only the loaded turn renders a row; the trimmed t1 does not. + expect(tmRows).toHaveLength(1); + expect(tmRows[0]?.turn.turnId).toBe("t2"); + // Cumulative includes BOTH turns (t1 + t2): input 3000, cacheRead 2100. + expect(tmRows[0]?.cumulativeUsage.inputTokens).toBe(3000); + expect(tmRows[0]?.cumulativeUsage.cacheReadTokens).toBe(2100); + // Retention baseline is the prior finalized turn (t1, even though trimmed). + expect(tmRows[0]?.prevTurnUsage?.inputTokens).toBe(1000); + expect(tmRows[0]?.prevTurnUsage?.cacheReadTokens).toBe(500); + }); + it("in-flight turn (no durationMs) still produces turn row", () => { const g1 = userGroup(1, "q1"); const g2 = toolCallGroup(2, "s1", "c1"); @@ -391,7 +466,7 @@ describe("interleaveTurnMetrics", () => { expectTurnMetricsAt(rows, 4, "t1"); }); - it("more metrics than segments: unmatched entry emits standalone turn-metrics", () => { + it("trimmed turn (more metrics than segments) does NOT emit a standalone row at the top", () => { const g1 = userGroup(1, "q1"); const g2 = toolCallGroup(2, "s1", "c1"); const step1 = makeStep("s1", 100, 50); @@ -401,13 +476,19 @@ describe("interleaveTurnMetrics", () => { [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], ); - // Unmatched entry (t2) emits a standalone turn-metrics row at the top. - expect(rows).toHaveLength(5); - expectTurnMetricsAt(rows, 0, "t2"); - expectGroupAt(rows, 1, g1); - expectGroupAt(rows, 2, g2); - expectStepMetricsAt(rows, 3, "s1", 0); - expectTurnMetricsAt(rows, 4, "t1"); + // t2's content was unloaded by the chat limit (no segment for it); its + // metrics must NOT render a standalone row piled at the top. Only the + // loaded turn's content + its matched metrics appear. (t2 still counts + // toward the cumulative "chat total" — see the cache-total tests.) + expect(rows).toHaveLength(4); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + // No standalone turn-metrics row for t2 anywhere. + const tmRows = rows.filter((r) => r.kind === "turn-metrics"); + expect(tmRows).toHaveLength(1); + expect((tmRows[0] as { readonly turn: TurnMetrics }).turn.turnId).toBe("t1"); }); it("turn with no steps emits only turn-metrics (no step-metrics)", () => { diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts index b165fd0..7122b09 100644 --- a/src/core/metrics/place.ts +++ b/src/core/metrics/place.ts @@ -27,10 +27,11 @@ function addUsage(a: Usage, b: Usage): Usage { * Splits groups into per-turn segments: a new segment begins at each `single` * group with `group.chunk.role === "user"`. Segments are matched to entries * by `stepId` presence when possible (robust against chat-limit trimming: when - * a turn's user message is trimmed, head-alignment would be off by one, but + * a turn's user message is trimmed, positional alignment would be off, but * stepId matching still finds the right entry). Segments with no stepId-bearing - * groups (text-only turns) fall back to sequential matching against unused - * entries. + * groups (text-only turns) fall back to POSITIONAL tail-alignment: since the + * loaded transcript is always a SUFFIX of the full turn history (the chat limit + * keeps the newest and unloads the oldest), segment `seg` ↔ entry `K - T + seg`. * * Within a segment that has a matched entry, each completed step's metrics * are placed INLINE right after the last group bearing that step's `stepId`. @@ -44,9 +45,13 @@ function addUsage(a: Usage, b: Usage): Usage { * is finalized via `done` or durable data). A still-generating turn emits no * turn-total row. * - * Cumulative usage is computed across finalized turns in entry-array order - * (turn order), so the per-turn "chat total" cache rate is correct regardless - * of which turns were trimmed. + * Fully trimmed turns (entries whose content was unloaded by the chat limit and + * which match no segment) are NOT rendered as standalone rows — that previously + * piled a wall of stale cache badges at the top of a long, trimmed transcript. + * Their usage still counts toward the per-turn "chat total" cumulative (computed + * across ALL finalized turns in entry-array order), so the running cache rate + * stays correct regardless of which turns were trimmed; paging earlier history + * back in ("Show earlier messages") re-matches them and re-renders their rows. */ export function interleaveTurnMetrics( groups: readonly RenderGroup[], @@ -84,8 +89,9 @@ export function interleaveTurnMetrics( const entryStepIds: Set<string>[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId))); // Match segments to entries. Pass 1: match by stepId overlap (handles - // trimming where head-alignment would be wrong). Pass 2: sequential fallback - // for unmatched segments (text-only turns with no stepId-bearing groups). + // trimming where positional alignment alone could be ambiguous). Pass 2: + // positional tail-alignment fallback for unmatched segments (text-only turns + // with no stepId-bearing groups). const usedEntries = new Set<number>(); const segmentEntry = new Map<number, TurnMetricsEntry>(); const segmentEntryIndex = new Map<number, number>(); @@ -127,19 +133,36 @@ export function interleaveTurnMetrics( } } - // Pass 2: sequential fallback for unmatched segments. - // If NO segments were matched by stepId (pass 1), use TAIL-ALIGNMENT: - // the loaded chunks are always the NEWEST (chat-limit/windowing keeps the - // newest and trims the oldest), so match the LAST T entries to the T - // segments. This prevents misaligning oldest (trimmed) entries to newest - // segments — which would show "turn 1" on turn 20's content. - const pass1Matches = segmentEntry.size; - if (pass1Matches === 0 && K >= T) { + // Pass 2: positional fallback for segments pass 1 left unmatched + // (text-only turns with no stepId-bearing groups to anchor on). + // + // The loaded transcript is always a SUFFIX of the full turn history — + // chat-limit/windowing keeps the NEWEST chunks and unloads the OLDEST — so + // the T loaded segments correspond to the LAST T entries. TAIL-ALIGNMENT + // (segment `seg` ↔ entry `K - T + seg`) is therefore correct whenever the + // metrics hold at least as many turns as there are loaded segments + // (`K >= T`): the leading `K - T` entries are TRIMMED turns (their content + // was unloaded) and must be skipped, never matched to a newer segment. + // + // This MUST run even when pass 1 matched SOME segments (tool turns). The + // earlier code only tail-aligned when pass 1 matched NONE, falling back to + // HEAD-alignment otherwise — which, with leading trimmed entries, matched a + // brand-new text-only turn to an old (trimmed) entry's STALE metrics (the + // "new steps show no / wrong cache" failure). Tail-aligning by position is + // safe alongside pass 1: stepIds are unique per turn, so pass 1 already + // grabbed each tool turn's positionally-correct entry, leaving the right + // entry free for each text-only turn. + // + // Only when `K < T` (fewer entries than segments — some loaded turns have no + // metrics yet, e.g. a metrics sync still pending or a freshly loaded + // transcript) do we head-align, assigning the first K entries to the first K + // unmatched segments (the turns that DO have metrics sit at the front). + if (K >= T) { // Tail-align: skip the first K-T entries (trimmed turns). for (let seg = 0; seg < T; seg++) { if (segmentEntry.has(seg)) continue; const entryIdx = K - T + seg; - if (entryIdx < K && !usedEntries.has(entryIdx)) { + if (entryIdx >= 0 && entryIdx < K && !usedEntries.has(entryIdx)) { usedEntries.add(entryIdx); const e = entries[entryIdx]; if (e !== undefined) { @@ -149,7 +172,7 @@ export function interleaveTurnMetrics( } } } else { - // Head-align fallback for remaining unmatched segments. + // Head-align fallback (K < T): first K entries to first K unmatched segments. let nextUnused = 0; for (let seg = 0; seg < T; seg++) { if (segmentEntry.has(seg)) continue; @@ -186,22 +209,6 @@ export function interleaveTurnMetrics( const firstUserIdx = segmentStarts[0] ?? 0; - // Emit turn-metrics rows for entries that weren't matched to any segment - // (fully trimmed turns — their content was unloaded by the chat limit, but - // their aggregate metrics still show so the user knows what was trimmed). - for (let i = 0; i < entries.length; i++) { - if (usedEntries.has(i)) continue; - const e = entries[i]; - if (e === undefined || e.total === null) continue; - rows.push({ - kind: "turn-metrics", - turn: e.total, - turnNumber: i + 1, - cumulativeUsage: cumulativeByEntry[i] ?? e.total.usage, - prevTurnUsage: prevUsageByEntry[i] ?? null, - }); - } - for (let i = 0; i < firstUserIdx; i++) { const g = groups[i]; if (g !== undefined) { diff --git a/src/core/metrics/reducer.test.ts b/src/core/metrics/reducer.test.ts index 7d0a270..581a8b7 100644 --- a/src/core/metrics/reducer.test.ts +++ b/src/core/metrics/reducer.test.ts @@ -439,4 +439,251 @@ describe("contextSize / selectCurrentContextSize", () => { ]); expect(selectCurrentContextSize(s)).toBe(222); }); + + it("in-flight turn updates context size after the first step completes", () => { + // Before the requirement: an in-flight turn had total=null so its step usage + // was ignored until `done`. Now the latest step's input+output is used. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + + // Still generating (no done) — context = step 1 input+output = 5200. + expect(selectCurrentContextSize(s)).toBe(5200); + }); + + it("in-flight turn updates progressively as each step reports usage", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5200); + + // Step 2 reports usage mid-stream (before its step-complete): each step's + // input already includes all prior context, so the last step's input+output + // is the current occupancy. + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("in-flight context size is the latest step with usage, NOT the aggregate sum", () => { + // Mirrors the finalized-turn test: contextSize is the FINAL step's + // input+output, not the sum across steps (which would overcount a + // multi-step turn because every step re-prefills the growing prompt). + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + // Aggregate would be 300+130=430; the latest step is 200+80=280. + expect(selectCurrentContextSize(s)).toBe(280); + }); + + it("in-flight turn with a step-complete but no usage falls back to older turn", () => { + // step-complete before usage → the step has no usage yet, so the in-flight + // turn exposes no context size and the display falls back to the prior + // finalized turn's value (never 0). + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1", { genTotalMs: 500 })); + + expect(selectCurrentContextSize(s)).toBe(700); + }); + + it("in-flight turn with no steps/usage returns undefined (falls back)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + // t2 just started — no usage, no complete step — omitted entirely. + s = foldMetricsEvent(s, { type: "turn-start", conversationId: "c1", turnId: "t2" }); + expect(selectCurrentContextSize(s)).toBe(700); + + // t2's first step reports usage → the display jumps to t2's live value. + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("done finalizes the in-flight progressive value with the authoritative contextSize", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5200); + + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + + // done stamps the authoritative contextSize (the final step's input+output). + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 5350 })); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("in-flight context size excludes cache tokens (they are a subset of inputTokens)", () => { + // cacheReadTokens / cacheWriteTokens are portions of inputTokens already + // counted — adding them would double-count. Only input+output is occupancy. + let s = initialMetricsState(); + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s1" as StepId, + usage: { + inputTokens: 5000, + outputTokens: 200, + cacheReadTokens: 4000, + cacheWriteTokens: 1000, + }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // 5000+200=5200, NOT 9200 (with cacheRead) or 10200 (with both). + expect(selectCurrentContextSize(s)).toBe(5200); + }); + + it("multiple in-flight turns: the newest turn's live value wins", () => { + let s = initialMetricsState(); + // t1 (older) in-flight with one completed step → 5200. + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // t2 (newer, seen later → last in liveOrder) in-flight → 8000. + s = foldMetricsEvent(s, usageEvent("t2", 7800, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + expect(selectCurrentContextSize(s)).toBe(8000); + }); + + it("out-of-order step IDs: usage for step 2 before step 1's step-complete still scans newest-first", () => { + // stepOrder is FIRST-SEEN: s1 (its usage arrived first), then s2. So s2 is + // the newest step regardless of when each step's step-complete arrives. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + // Neither step complete yet → the turn is omitted (no complete step), so the + // display can't update until the first step completes. + expect(selectCurrentContextSize(s)).toBeUndefined(); + + // s1 completes AFTER s2's usage was reported. The turn is now visible; the + // newest-first scan picks s2 (the later step), not s1 (the just-completed one). + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5350); + + // s2 completes — still s2, unchanged. + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("done turn without contextSize falls back to an older turn (even with step usage)", () => { + // Contract lock-in: a done turn's step usage is NOT consulted for the + // context display — only its authoritative total.contextSize is. When that + // is absent, the display falls back to the next older finalized turn rather + // than synthesizing a value from the step usage. + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + // t2 done WITH step usage but NO done.contextSize (edge case: the done event + // omitted contextSize despite per-step usage). + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + s = foldMetricsEvent(s, doneEvent("t2")); + expect(selectCurrentContextSize(s)).toBe(700); + }); + + it("in-flight context size skips a step with unsafe usage (NaN / negative)", () => { + // A corrupt provider report must never reach the status bar. The newest + // step with invalid counters is skipped, falling back to the prior valid one. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // s2 reports NaN input (e.g. a non-numeric provider field coerced). + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s2" as StepId, + usage: { inputTokens: Number.NaN, outputTokens: 150 }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + // s2 skipped (NaN) → falls back to s1's 5200, NOT NaN. + expect(selectCurrentContextSize(s)).toBe(5200); + + // Negative tokens are likewise skipped. + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s3" as StepId, + usage: { inputTokens: -10, outputTokens: 5 }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s3")); + expect(selectCurrentContextSize(s)).toBe(5200); + }); +}); + +describe("applyDurableMetrics pruning", () => { + it("prunes a live turn once durable data covers it (no unbounded growth)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 })); + expect(s.live.has("t1")).toBe(true); + expect(s.liveOrder).toContain("t1"); + + s = applyDurableMetrics(s, [ + { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [{ stepId: "s1" as StepId, usage: { inputTokens: 100, outputTokens: 50 } }], + contextSize: 150, + }, + ]); + // The live copy is gone; the durable (authoritative) entry replaces it. + expect(s.live.has("t1")).toBe(false); + expect(s.liveOrder).not.toContain("t1"); + expect(s.durable.has("t1")).toBe(true); + // The display still reads the durable value atomically (no gap). + expect(selectCurrentContextSize(s)).toBe(150); + }); + + it("prunes only the turns present in the durable batch (leaves other live turns)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 })); + // t2 still in flight — must NOT be pruned when only t1 seals. + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 100, outputTokens: 50 }, steps: [], contextSize: 150 }, + ]); + expect(s.live.has("t1")).toBe(false); + expect(s.live.has("t2")).toBe(true); + expect(s.liveOrder).toEqual(["t2"]); + // The newest (in-flight) turn's live value still wins. + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("is a no-op when no incoming turn is live (no live mutation)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + const before = s; + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [] }, + ]); + // t1 was never live → the live map/order are unchanged (same reference). + expect(s.live).toBe(before.live); + expect(s.liveOrder).toBe(before.liveOrder); + // t1 (durable) is older; the in-flight t2 still wins. + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("durable wins over live for a shared turnId (pruned live no longer consulted)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 })); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 }, + ]); + // The live (111) copy is pruned; only durable (222) remains. + expect(s.live.has("t1")).toBe(false); + expect(selectCurrentContextSize(s)).toBe(222); + }); }); diff --git a/src/core/metrics/reducer.ts b/src/core/metrics/reducer.ts index bebef1d..39fc5ee 100644 --- a/src/core/metrics/reducer.ts +++ b/src/core/metrics/reducer.ts @@ -68,6 +68,51 @@ function liveTurnToMetrics(lt: LiveTurn): TurnMetrics { return base; } +/** + * A step's contribution to the live context size: `inputTokens + outputTokens`, + * or `undefined` when the step has no usage yet OR its counters are not safe to + * sum (non-finite / negative — defensive: a corrupt provider report must never + * reach the status bar as NaN/Infinity). Cache tokens are deliberately NOT + * included: `cacheReadTokens` / `cacheWriteTokens` are a SUBSET of + * `inputTokens`, so adding them would double-count. + */ +function stepContextSize(usage: Usage | undefined): number | undefined { + if (usage === undefined) return undefined; + const { inputTokens, outputTokens } = usage; + if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens)) return undefined; + if (inputTokens < 0 || outputTokens < 0) return undefined; + return inputTokens + outputTokens; +} + +/** + * The context size an IN-FLIGHT (not-done) turn occupies right now — for + * progressive display DURING a turn (before it seals), so the indicator updates + * after each step instead of waiting for `done`. + * + * CONTRACT: only call this on a turn whose `done` event has NOT arrived (the + * caller, `selectCurrentContextSize`, reaches it solely for entries with + * `total === null`, i.e. `lt.done === false`). Finalized turns use their + * authoritative `contextSize` instead; `doneContextSize` is read on the + * `total` path, never here. + * + * Returns the most recent step WITH USABLE USAGE's `inputTokens + outputTokens` + * (scanning newest → oldest by first-seen step order): each step's input + * already includes all prior context (the prompt is re-prefilled every step), so + * the last step's input+output is the true occupancy — the same definition + * `TurnDoneEvent.contextSize` stamps at turn end. A just-reported step's usage + * wins immediately, even mid-stream. Steps with no usage or unsafe usage are + * skipped, falling back to the next older usable step. `undefined` when no step + * has reported usable usage yet. + */ +function liveTurnContextSize(lt: LiveTurn): number | undefined { + for (let i = lt.stepOrder.length - 1; i >= 0; i--) { + const step = lt.stepMap.get(lt.stepOrder[i] ?? ""); + const ctx = stepContextSize(step?.usage); + if (ctx !== undefined) return ctx; + } + return undefined; +} + function ensureLiveTurn(state: MetricsState, turnId: string): [MetricsState, LiveTurn] { const existing = state.live.get(turnId); if (existing !== undefined) return [state, existing]; @@ -180,6 +225,12 @@ export function foldMetricsEvent(state: MetricsState, event: AgentEvent): Metric /** * Store durable (sealed) metrics from the backend. These win over live data * for any shared `turnId`. + * + * Once durable (authoritative) data covers a turn, its live (in-memory) copy + * is REDUNDANT and is pruned from `state.live` / `liveOrder` so the live map + * doesn't grow unbounded over a long conversation. There is no display gap: + * the durable entry replaces the live one atomically in the same fold, and + * `selectOrderedTurnMetrics` / `selectCurrentContextSize` read durable for it. */ export function applyDurableMetrics( state: MetricsState, @@ -187,14 +238,27 @@ export function applyDurableMetrics( ): MetricsState { const newDurable = new Map(state.durable); const newDurableOrder = [...state.durableOrder]; + const prunedIds = new Set<string>(); for (const turn of turns) { if (!newDurable.has(turn.turnId)) { newDurableOrder.push(turn.turnId); } newDurable.set(turn.turnId, turn); + if (state.live.has(turn.turnId)) prunedIds.add(turn.turnId); } + + if (prunedIds.size === 0) { + return { ...state, durable: newDurable, durableOrder: newDurableOrder }; + } + + const newLive = new Map(state.live); + for (const id of prunedIds) newLive.delete(id); + const newLiveOrder = state.liveOrder.filter((id) => !prunedIds.has(id)); + return { ...state, + live: newLive, + liveOrder: newLiveOrder, durable: newDurable, durableOrder: newDurableOrder, }; @@ -247,17 +311,35 @@ export function selectOrderedTurnMetrics(state: MetricsState): readonly TurnMetr * Select the conversation's CURRENT context size — the tokens it occupies right * now. Per the wire contract a client reads the LATEST turn's `contextSize`; we * scan the merged ordered turns NEWEST → OLDEST and return the first DEFINED - * `contextSize` (a finalized turn whose provider reported per-step usage). + * value. + * + * For a FINALIZED turn (`done` event or durable data) we use its authoritative + * `contextSize`. For an IN-FLIGHT (not-done) turn we compute it PROGRESSIVELY + * from the most recent step WITH USAGE — its `inputTokens + outputTokens` is the + * current occupancy (mirroring `TurnDoneEvent.contextSize`'s definition) — so + * the indicator updates after each step completes instead of waiting for the + * turn to seal. An in-flight turn with no step usage yet is skipped, falling + * back to the next older finalized turn. * - * Returns `undefined` ("unknown") when no finalized turn carries a context size — - * the caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over + * Returns `undefined` ("unknown") when no turn carries a context size — the + * caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over * live for a shared `turnId` (it is the persisted, authoritative value). */ export function selectCurrentContextSize(state: MetricsState): number | undefined { const ordered = selectOrderedTurnMetrics(state); for (let i = ordered.length - 1; i >= 0; i--) { - const total = ordered[i]?.total; - if (total?.contextSize !== undefined) return total.contextSize; + const entry = ordered[i]; + if (entry === undefined) continue; + if (entry.total !== null) { + if (entry.total.contextSize !== undefined) return entry.total.contextSize; + continue; + } + // In-flight turn: progressive context size from the latest step with usage. + const lt = state.live.get(entry.turnId); + if (lt !== undefined) { + const live = liveTurnContextSize(lt); + if (live !== undefined) return live; + } } return undefined; } diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 5278737..9911438 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -74,9 +74,12 @@ export interface ChatStore { readonly chunks: readonly RenderedChunk[]; readonly turnMetrics: readonly TurnMetricsEntry[]; /** - * The conversation's current context size (tokens occupied) — the latest - * finalized turn's `contextSize`, or `undefined` ("unknown") when none is - * known yet. Never `0` for the unknown case. + * The conversation's current context size (tokens occupied) — updated + * PROGRESSIVELY: during an in-flight turn, the most recent step's + * `inputTokens + outputTokens` (each step's input already includes all prior + * context); once the turn seals, its authoritative `contextSize`. `undefined` + * ("unknown") when no step has reported usage yet. Never `0` for the unknown + * case. */ readonly currentContextSize: number | undefined; /** diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index afe1e3c..04c28cd 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -39,8 +39,10 @@ onQueue?: (text: string) => void; /** Stop the in-flight generation (`POST /conversations/:id/stop`). */ onStop?: () => void; - // Current context occupancy (latest turn's contextSize), or `undefined` - // when unknown — the status bar then shows "— tokens", never 0%. + // Current context occupancy — updated progressively during a turn (the + // latest step's input+output) and finalized to the turn's `contextSize` on + // seal, or `undefined` when unknown — the status bar then shows + // "— tokens", never 0%. contextSize?: number | undefined; /** Per-model context window (max tokens) from `GET /models` modelInfo. */ contextWindow?: number | undefined; @@ -66,7 +68,6 @@ const canSend = $derived(hasText || hasImages); const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW); const usage = $derived(computeContextUsage(contextSize, effectiveMax)); - const hasUsage = $derived(contextSize !== undefined); // One button, three modes: // - idle → "Send" (starts a turn via chat.send) @@ -395,7 +396,7 @@ {/if} <span class="shrink-0 whitespace-nowrap font-mono"> - {#if hasUsage} + {#if usage.current !== null} {formatCompactTokens(usage.current)}{#if usage.max !== null}<span class="text-base-content/40" > diff --git a/src/features/concurrency/index.ts b/src/features/concurrency/index.ts index 151acb8..95eb5ed 100644 --- a/src/features/concurrency/index.ts +++ b/src/features/concurrency/index.ts @@ -1,7 +1,9 @@ export type { + // Contract shapes re-exported for a single import surface. + ConcurrencyCooldownResponse, + ConcurrencyCooldownResult, ConcurrencyDeleteResult, ConcurrencyLimitEntry, - // Contract shapes re-exported for a single import surface. ConcurrencyLimitResponse, ConcurrencyLimitResult, ConcurrencyLimitsResponse, @@ -10,30 +12,47 @@ export type { ConcurrencyStatusResponse, ConcurrencyStatusResult, DeleteConcurrencyLimit, + GetConcurrencyCooldown, GetConcurrencyLimit, LoadConcurrencyLimits, LoadConcurrencyStatus, + RestoreOutcome, + SaveConcurrencyCooldown, SaveConcurrencyLimit, + SetConcurrencyCooldownRequest, SetConcurrencyLimitRequest, } from "./logic/types"; -export type { Badge, ConcurrencyLimitView, ConcurrencyStatusView } from "./logic/view-model"; +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, summarizeLimits, summarizeStatus, + viewAutoReduce, viewConcurrencyLimit, viewConcurrencyLimits, viewConcurrencyStatus, viewConcurrencyStatuses, } from "./logic/view-model"; +export { default as AutoReduceBanner } from "./ui/AutoReduceBanner.svelte"; +export { default as ConcurrencyCooldownRow } from "./ui/ConcurrencyCooldownRow.svelte"; export { default as ConcurrencyLimitRow } from "./ui/ConcurrencyLimitRow.svelte"; export { default as ConcurrencyView } from "./ui/ConcurrencyView.svelte"; diff --git a/src/features/concurrency/logic/types.ts b/src/features/concurrency/logic/types.ts index f05211f..a0c5f6b 100644 --- a/src/features/concurrency/logic/types.ts +++ b/src/features/concurrency/logic/types.ts @@ -1,8 +1,10 @@ import type { + ConcurrencyCooldownResponse, ConcurrencyLimitResponse, ConcurrencyLimitsResponse, ConcurrencyStatusEntry, ConcurrencyStatusResponse, + SetConcurrencyCooldownRequest, SetConcurrencyLimitRequest, } from "@dispatch/transport-contract"; @@ -23,14 +25,23 @@ import type { * 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, }; @@ -69,6 +80,25 @@ 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). ────────────────────────────────────── @@ -80,3 +110,10 @@ export type SaveConcurrencyLimit = ( ) => 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 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(); + }); }); diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts index 7a4fe0e..e05423b 100644 --- a/src/features/concurrency/logic/view-model.ts +++ b/src/features/concurrency/logic/view-model.ts @@ -1,12 +1,16 @@ -import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; +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, summaries), - * holds the limit-input parsing, and the network-seam normalizers the composition - * root coerces the untyped JSON with. + * 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"; @@ -36,6 +40,29 @@ export interface ConcurrencyStatusView { 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 ─────────────────────────────────────────────────────── @@ -63,6 +90,53 @@ export function normalizeLimit(value: unknown): number { 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 @@ -140,6 +214,10 @@ export function pauseLabel( * 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, @@ -149,12 +227,21 @@ export function viewConcurrencyStatus( 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, @@ -166,9 +253,53 @@ export function viewConcurrencyStatus( pausedLabel: pauseLabel(paused, entry.pausedUntil, now), badge, busy: paused || (atCapacity && queued > 0), + cooldownMs, + cooldownLabel: cooldownLabel(cooldownMs), + autoReduced, + autoReducedFrom, }; } +/** + * 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(), @@ -197,8 +328,8 @@ export function summarizeLimits(limits: readonly ConcurrencyLimitEntry[]): strin /** * A one-line summary of the live status, e.g. - * "2 providers · 6/10 in flight · 1 queued · 1 paused". Only the queued / paused - * fragments appear when non-zero. + * "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[], @@ -209,12 +340,14 @@ export function summarizeStatus( 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( @@ -223,6 +356,7 @@ export function summarizeStatus( ); 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(" · "); @@ -274,7 +408,9 @@ export function normalizeConcurrencyLimit(data: unknown): ConcurrencyLimitEntry /** * 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). + * 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 []; @@ -282,18 +418,57 @@ export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyS return providers .filter((r): r is Record<string, unknown> => isRecord(r)) .map((r): ConcurrencyStatusEntry => { - const base = { - providerId: asString(r.providerId) ?? "", - limit: normalizeLimit(r.limit), - inFlight: clampCount(r.inFlight), - queued: clampCount(r.queued), - paused: r.paused === true, + 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, }; - const pausedUntil = - typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil) - ? r.pausedUntil - : undefined; - return pausedUntil !== undefined ? { ...base, pausedUntil } : base; + 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/ConcurrencyCooldownRow.svelte b/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte new file mode 100644 index 0000000..03ddec9 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte @@ -0,0 +1,93 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { parseCooldownInput } from "../logic/view-model"; + import type { SaveConcurrencyCooldown } from "../logic/types"; + + let { + providerId, + cooldownMs, + save, + }: { + /** The provider this cooldown row controls. */ + providerId: string; + /** The current per-slot release cooldown (ms) from the live status poll. */ + cooldownMs: number; + save: SaveConcurrencyCooldown; + } = $props(); + + // Inline-edit state: the raw text bound to the cooldown input. Seeded from the + // row's canonical cooldownMs, but only while the field is untouched — so a + // status-poll refresh re-syncs it without clobbering an in-flight edit. Mirrors + // the ConcurrencyLimitRow / ChatLimitField seed pattern. + let draft = $state(""); + let lastSeed = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + /** Brief "Saved." confirmation after a successful save; cleared on edit. */ + let justSaved = $state(false); + + $effect(() => { + const incoming = String(cooldownMs); + untrack(() => { + if (draft === lastSeed) draft = incoming; + lastSeed = incoming; + }); + }); + + const parsed = $derived(parseCooldownInput(draft)); + const dirty = $derived(parsed !== null && parsed !== cooldownMs); + + function onInput(): void { + justSaved = false; + error = null; + } + + async function handleSave(): Promise<void> { + if (parsed === null || parsed === cooldownMs) return; + saving = true; + error = null; + const result = await save(providerId, parsed); + saving = false; + if (result.ok) { + // Reflect the echoed cooldown back into the field immediately (the prop + // also re-asserts it via the seed effect above once the parent reloads). + draft = String(result.cooldownMs); + lastSeed = draft; + justSaved = true; + } else { + error = result.error; + } + } +</script> + +<div class="flex items-center gap-2 text-xs"> + <span class="opacity-60">cooldown</span> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-20 font-mono" + aria-label={`Release cooldown (ms) for ${providerId}`} + bind:value={draft} + oninput={onInput} + disabled={saving} + /> + <span class="opacity-50">ms</span> + <button + type="button" + class="btn btn-primary btn-xs" + aria-label={`Save cooldown for ${providerId}`} + disabled={!dirty || saving} + onclick={handleSave} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Save + {/if} + </button> + {#if error} + <span class="font-mono text-error">{error}</span> + {:else if justSaved && !dirty} + <span class="text-success">Saved.</span> + {/if} +</div> diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte index f8a2199..6078b1a 100644 --- a/src/features/concurrency/ui/ConcurrencyView.svelte +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -2,6 +2,7 @@ import { untrack } from "svelte"; import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; import { + autoReduceNotices, type Badge, parseLimitInput, providerOptions, @@ -15,8 +16,12 @@ DeleteConcurrencyLimit, LoadConcurrencyLimits, LoadConcurrencyStatus, + RestoreOutcome, + SaveConcurrencyCooldown, SaveConcurrencyLimit, } from "../logic/types"; + import AutoReduceBanner from "./AutoReduceBanner.svelte"; + import ConcurrencyCooldownRow from "./ConcurrencyCooldownRow.svelte"; import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte"; let { @@ -25,6 +30,7 @@ saveLimit, deleteLimit, loadStatus, + saveCooldown, }: { /** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */ models: readonly string[]; @@ -32,6 +38,7 @@ saveLimit: SaveConcurrencyLimit; deleteLimit: DeleteConcurrencyLimit; loadStatus: LoadConcurrencyStatus; + saveCooldown: SaveConcurrencyCooldown; } = $props(); const badgeClass: Record<Badge, string> = { @@ -136,6 +143,18 @@ return result; } + // Wrap the cooldown save so a successful PUT refreshes the live status (which + // re-carries the new `cooldownMs`). The row still gets the result to drive its + // own UI. `getCooldown` is exposed for completeness/future use (the live status + // already carries `cooldownMs`, so the row seeds from the status view). + async function cooldownSave(providerId: string, cooldownMs: number) { + const result = await saveCooldown(providerId, cooldownMs); + if (result.ok) { + void refreshStatus(); + } + return result; + } + // ── Live status (polls while mounted) ─────────────────────────────────────── let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]); let statusError = $state<string | null>(null); @@ -158,6 +177,69 @@ const statusViews = $derived(viewConcurrencyStatuses(statusEntries, now)); const statusSummary = $derived(summarizeStatus(statusEntries, now)); + // ── Auto-reduce banners (persist while autoReduced===true; dismissible) ─────── + // + // When a provider's limit is auto-reduced by a 429, `GET /concurrency/status` + // carries `autoReduced: true` (+ `autoReducedFrom` + `notice`). We render a + // banner per such provider. The banner is DISMISSIBLE: a dismissed provider + // stays hidden while it remains auto-reduced (persist-while-true), and is + // UN-dismissed the moment a poll shows it no longer auto-reduced — so a future + // auto-reduce re-shows the banner. Restoring the limit (PUT) clears + // `autoReduced` server-side → the next poll drops the banner automatically. + // + // The dismissed set is intentionally COMPONENT-LOCAL (NOT persisted to + // localStorage / a module-global): it resets on remount (sidebar view switch / + // reload). This is correct — `autoReduced` is a REAL persisted degraded state, + // so re-showing the banner on a fresh mount reminds the user. Persisting a + // dismissal across reloads would risk HIDING an ongoing degradation (a + // footgun), and AGENTS.md forbids module-global ambient state. Mirrors the + // component-local `limitsError`/`statusError` pattern. + let dismissedAutoReduce = $state<ReadonlySet<string>>(new Set()); + + const allNotices = $derived(autoReduceNotices(statusEntries)); + const visibleNotices = $derived( + allNotices.filter((n) => !dismissedAutoReduce.has(n.providerId)), + ); + + // Reconcile the dismissed set against the live auto-reduced providers: keep a + // dismissed entry ONLY while its provider is still auto-reduced. A provider + // that has been restored (no longer in `allNotices`) is dropped from the + // dismissed set so a future auto-reduce re-shows its banner. + $effect(() => { + const autoReducedIds = new Set(allNotices.map((n) => n.providerId)); + untrack(() => { + let changed = false; + const next = new Set<string>(); + for (const id of dismissedAutoReduce) { + if (autoReducedIds.has(id)) next.add(id); + else changed = true; + } + if (changed) dismissedAutoReduce = next; + }); + }); + + function dismissAutoReduce(providerId: string): void { + if (dismissedAutoReduce.has(providerId)) return; + dismissedAutoReduce = new Set([...dismissedAutoReduce, providerId]); + } + + // "Restore to N" — PUT the limit back to `autoReducedFrom` via the limits + // endpoint (a manual PUT clears `autoReduced` server-side). Refreshes limits + + // status on success; the next status poll shows `autoReduced===false` and the + // banner drops (the dismissed-set effect above un-dismisses it too). The banner + // component owns its own restoring-spinner + inline error; on FAILURE the + // outcome is bubbled back so the banner shows the error inline (instead of + // silently re-enabling the button / surfacing it only in the limits section). + async function restoreLimit(providerId: string, limit: number): Promise<RestoreOutcome> { + const result = await saveLimit(providerId, limit); + if (result.ok) { + void refreshLimits(); + void refreshStatus(); + return { ok: true }; + } + return { ok: false, error: result.error }; + } + async function refreshStatus(): Promise<void> { if (statusInFlight) return; statusInFlight = true; @@ -192,6 +274,19 @@ </script> <div class="flex flex-col gap-4"> + <!-- Auto-reduce banners (appear when a provider's limit was auto-reduced by a 429) --> + {#if visibleNotices.length > 0} + <section class="flex flex-col gap-2" aria-label="Concurrency auto-reduce notices"> + {#each visibleNotices as notice (notice.providerId)} + <AutoReduceBanner + {notice} + onRestore={restoreLimit} + onDismiss={dismissAutoReduce} + /> + {/each} + </section> + {/if} + <!-- Limits (config) --> <section class="flex flex-col gap-2"> <div class="flex items-center justify-between gap-2"> @@ -319,10 +414,22 @@ <div class="flex flex-wrap items-center justify-between gap-2 text-xs opacity-70"> <span title="In-flight slots held vs cap">{s.inFlightLabel} in flight</span> <span>{s.queuedLabel}</span> + <span title="Per-slot release cooldown">cooldown {s.cooldownLabel}</span> </div> {#if s.pausedLabel} <span class="text-xs text-warning">{s.pausedLabel}</span> {/if} + {#if s.autoReduced} + <span class="text-xs text-warning"> + Limit auto-reduced{#if s.autoReducedFrom !== null} + from {s.autoReducedFrom} to {s.limit}{/if}. + </span> + {/if} + <ConcurrencyCooldownRow + providerId={s.providerId} + cooldownMs={s.cooldownMs} + save={cooldownSave} + /> </li> {/each} </ul> diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts index 3dc8e78..854f13b 100644 --- a/src/features/concurrency/ui/ConcurrencyView.test.ts +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import type { + ConcurrencyCooldownResult, ConcurrencyDeleteResult, ConcurrencyLimitResult, ConcurrencyLimitsResult, @@ -13,35 +14,80 @@ import ConcurrencyView from "./ConcurrencyView.svelte"; // Available models → provider ids are "umans", "anthropic", "openai-compat". const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const; -// Fakes for the four injected ports. Each resolves immediately so the mount -// effect's initial load settles in a microtask (assertions await via findBy*). +// A status entry factory (defaults to a healthy limited provider). The new +// concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present. +function statusEntry(over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry { + return { + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 1, + paused: false, + cooldownMs: 350, + autoReduced: false, + ...over, + }; +} +// Fakes for the injected ports. Each resolves immediately so the mount effect's +// initial load settles in a microtask (assertions await via findBy*). The status +// list is mutable so a test can flip `autoReduced` between polls to simulate a +// restore clearing the banner. function makeFakes(opts?: { limits?: readonly { providerId: string; limit: number }[]; - status?: readonly ConcurrencyStatusEntry[]; + status?: ConcurrencyStatusEntry[]; + /** + * When set, `saveLimit` rejects with this error (returns `ok: false`) — used + * to test the auto-reduce banner's inline restore-error feedback. + */ + saveLimitError?: string; + /** + * Optional hook invoked inside `saveLimit` AFTER recording the call. Lets a + * test simulate a backend side-effect of the PUT (e.g. clearing `autoReduced` + * on the next status poll). Receives the providerId + limit + the fakes bag so + * it can mutate the status list. (A plain method reassignment would NOT reach + * the already-rendered component — the prop captured the original closure.) + */ + onSaveLimit?: ( + providerId: string, + limit: number, + self: { calls: MakeFakesCalls; setStatus: (next: ConcurrencyStatusEntry[]) => void }, + ) => void; }) { let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }]; - const status = opts?.status ?? [ - { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, - ]; + let status = opts?.status ?? [statusEntry()]; + const onSaveLimit = opts?.onSaveLimit; + const saveLimitError = opts?.saveLimitError; - const calls = { + const calls: MakeFakesCalls = { loadLimits: 0, loadStatus: 0, saves: [] as { providerId: string; limit: number }[], deletes: [] as string[], + cooldownSaves: [] as { providerId: string; cooldownMs: number }[], }; + function setStatus(next: ConcurrencyStatusEntry[]): void { + status = next; + } + return { calls, + // Allow a test to mutate the status list between polls (e.g. clear + // autoReduced after a restore to simulate the next poll). + setStatus, loadLimits: async (): Promise<ConcurrencyLimitsResult> => { calls.loadLimits++; return { ok: true, limits }; }, saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => { calls.saves.push({ providerId, limit }); + if (saveLimitError !== undefined) { + return { ok: false, error: saveLimitError }; + } // Reflect the new limit into the list the next load returns. limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }]; + if (onSaveLimit !== undefined) onSaveLimit(providerId, limit, { calls, setStatus }); return { ok: true, providerId, limit }; }, deleteLimit: async (providerId: string): Promise<ConcurrencyDeleteResult> => { @@ -53,21 +99,41 @@ function makeFakes(opts?: { calls.loadStatus++; return { ok: true, providers: status }; }, + saveCooldown: async ( + providerId: string, + cooldownMs: number, + ): Promise<ConcurrencyCooldownResult> => { + calls.cooldownSaves.push({ providerId, cooldownMs }); + // Reflect the new cooldown into the status list the next load returns. + status = status.map((s) => (s.providerId === providerId ? { ...s, cooldownMs } : s)); + return { ok: true, providerId, cooldownMs }; + }, + }; +} + +type MakeFakesCalls = { + loadLimits: number; + loadStatus: number; + saves: { providerId: string; limit: number }[]; + deletes: string[]; + cooldownSaves: { providerId: string; cooldownMs: number }[]; +}; + +function props(fakes: ReturnType<typeof makeFakes>) { + return { + models: MODELS as unknown as readonly string[], + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + saveCooldown: fakes.saveCooldown, }; } describe("ConcurrencyView", () => { it("loads + renders the configured limits and live status on mount", async () => { const fakes = makeFakes(); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); // The provider dropdown is populated from the available models' providers. const providerSelect = await screen.findByLabelText("Provider"); @@ -81,21 +147,21 @@ describe("ConcurrencyView", () => { expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1); }); + it("renders the per-provider cooldown label from the live status", async () => { + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + // The status card shows the cooldown alongside in-flight/queue. + expect(await screen.findByText(/cooldown 350ms/)).toBeInTheDocument(); + }); + it("surfaces NO loading indicator during refresh (background poll is silent — no flicker)", async () => { // The 2s status poll + post-mutation reloads are SILENT: they never toggle a // visible loading state, so the Refresh buttons are plain-text (no spinner) // and the list area never reflows mid-refresh. Regression guard for the // flicker fix (mirrors the heartbeat runs list). const fakes = makeFakes(); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); @@ -116,15 +182,7 @@ describe("ConcurrencyView", () => { it("adds a provider limit via the dropdown form (calls saveLimit + reloads)", async () => { const user = userEvent.setup(); const fakes = makeFakes({ limits: [], status: [] }); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); const providerSelect = await screen.findByLabelText("Provider"); // Choose "anthropic" from the dropdown (the list is auto-selected first). @@ -142,15 +200,7 @@ describe("ConcurrencyView", () => { it("disables Add when the limit is empty/invalid (provider is auto-selected)", async () => { const user = userEvent.setup(); const fakes = makeFakes({ limits: [], status: [] }); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); const providerSelect = await screen.findByLabelText("Provider"); // A provider is auto-selected from the dropdown. @@ -172,13 +222,7 @@ describe("ConcurrencyView", () => { it("shows no-providers + disables the dropdown when there are no models", async () => { const fakes = makeFakes({ limits: [], status: [] }); render(ConcurrencyView, { - props: { - models: [], - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, + props: { ...props(fakes), models: [] as unknown as readonly string[] }, }); const providerSelect = await screen.findByLabelText("Provider"); @@ -189,15 +233,7 @@ describe("ConcurrencyView", () => { it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => { const user = userEvent.setup(); const fakes = makeFakes(); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); // Wait for the limits to load (unique summary) before interacting. await screen.findByText(/1 limit configured/); @@ -209,7 +245,7 @@ describe("ConcurrencyView", () => { it("surfaces a load error from the limits endpoint", async () => { const failing = { - models: MODELS, + models: MODELS as unknown as readonly string[], loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({ ok: false, error: "Concurrency service not available", @@ -217,8 +253,191 @@ describe("ConcurrencyView", () => { saveLimit: async (): Promise<ConcurrencyLimitResult> => ({ ok: false, error: "noop" }), deleteLimit: async (): Promise<ConcurrencyDeleteResult> => ({ ok: false, error: "noop" }), loadStatus: async (): Promise<ConcurrencyStatusResult> => ({ ok: true, providers: [] }), + saveCooldown: async (): Promise<ConcurrencyCooldownResult> => ({ ok: false, error: "noop" }), }; render(ConcurrencyView, { props: failing }); expect(await screen.findByText("Concurrency service not available")).toBeVisible(); }); + + // ── Concurrency-fixes: auto-reduce banner + cooldown editing ──────────────── + + it("renders an auto-reduce banner (with the backend notice + Restore) when a provider is auto-reduced", async () => { + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.", + }), + ], + }); + render(ConcurrencyView, { props: props(fakes) }); + + // The banner shows the backend notice verbatim + a "Restore to 4" action. + expect(await screen.findByText(/auto-reduced to 3 after a 429/)).toBeVisible(); + expect(await screen.findByRole("button", { name: /Restore to 4/ })).toBeVisible(); + // The "Was 4, now 3." provenance line is shown. + expect(await screen.findByText(/Was 4, now 3\./)).toBeVisible(); + }); + + it("clears the banner after Restore (next status poll shows autoReduced===false)", async () => { + const user = userEvent.setup(); + // Start auto-reduced (limit 3, was 4). The restore PUT clears `autoReduced` + // server-side; the next status poll returns limit 4 + autoReduced===false → + // the banner drops. + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + onSaveLimit: (_providerId, limit, self) => { + // Simulate the backend clearing `autoReduced` on the manual PUT: the next + // status load returns the restored limit with autoReduced===false. + self.setStatus([statusEntry({ limit, autoReduced: false })]); + }, + }); + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + + // The restore PUT the limit back to the original (autoReducedFrom = 4). + expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]); + // The banner is gone (no Restore button, no notice text); the status summary + // now reflects the restored limit (2/4 in flight). + await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull(); + }); + + it("dismisses the auto-reduce banner locally while it stays auto-reduced", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + }); + render(ConcurrencyView, { props: props(fakes) }); + + await screen.findByRole("button", { name: /Restore to 4/ }); + // Dismiss the banner (hide locally — the provider is still auto-reduced). + await user.click(screen.getByLabelText("Dismiss auto-reduce notice for umans")); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull(); + }); + + it("shows an inline error in the banner when the Restore PUT fails (no silent re-enable)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + saveLimitError: "Concurrency service not available", + }); + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + + // The error surfaces INLINE in the banner (near the restore action), not + // only in the far-away limits section. The banner is still present (restore + // did not succeed) and the button re-enabled for a retry. + expect(await screen.findByTestId("restore-error-umans")).toHaveTextContent( + "Concurrency service not available", + ); + expect(screen.getByRole("button", { name: /Restore to 4/ })).toBeVisible(); + expect(screen.getByRole("button", { name: /Restore to 4/ })).not.toBeDisabled(); + // The restore PUT was attempted. + expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]); + }); + + it("clears the inline restore error on a retry that succeeds", async () => { + const user = userEvent.setup(); + // First restore fails; the second succeeds (clears autoReduced). Reassigning + // `fakes.saveLimit` BEFORE `props(fakes)` is captured would NOT reach the + // rendered component, so swap it BEFORE render here. + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + onSaveLimit: (_providerId, limit, self) => { + self.setStatus([statusEntry({ limit, autoReduced: false })]); + }, + }); + let attempts = 0; + const succeeding = fakes.saveLimit; + fakes.saveLimit = async (providerId, limit) => { + attempts++; + if (attempts === 1) return { ok: false, error: "Concurrency service not available" }; + return succeeding(providerId, limit); + }; + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + // First attempt: inline error appears. + expect(await screen.findByTestId("restore-error-umans")).toBeInTheDocument(); + + // Retry: the error clears, the banner drops (restore succeeded). + await user.click(screen.getByRole("button", { name: /Restore to 4/ })); + await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); + expect(screen.queryByTestId("restore-error-umans")).toBeNull(); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + }); + + it("edits the per-provider cooldown (PUT /concurrency/cooldown + reloads status)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + // Wait for the status card + its cooldown input (seeded with 350). + const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans"); + expect((cooldownInput as HTMLInputElement).value).toBe("350"); + + await user.clear(cooldownInput); + await user.type(cooldownInput, "500"); + await user.click(screen.getByRole("button", { name: "Save cooldown for umans" })); + + // The cooldown PUT fired with the new value. + expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "umans", cooldownMs: 500 }]); + // After save the component reloads status (which now carries cooldownMs=500). + expect(await screen.findByText(/cooldown 500ms/)).toBeInTheDocument(); + }); + + it("rejects a negative cooldown input (Save disabled — non-negative integer only)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans"); + // 0 is valid (no cooldown); a negative is not. + await user.clear(cooldownInput); + await user.type(cooldownInput, "0"); + expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeEnabled(); + + await user.clear(cooldownInput); + await user.type(cooldownInput, "-5"); + expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeDisabled(); + expect(fakes.calls.cooldownSaves).toHaveLength(0); + }); }); diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte index 561b06c..6de4109 100644 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -11,14 +11,27 @@ store, onNavigate, computers, + hasActive, }: { ws: WorkspaceEntry; store: WorkspaceStore; onNavigate: (path: string) => void; /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ computers: readonly ComputerEntry[]; + /** + * Optional port: returns whether the workspace has at least one active + * (generating / queued) conversation — drives a loading-dots indicator on + * the card. Wired by the composition root to the app store's + * `workspaceHasActiveConversations`. Absent → no indicator (e.g. tests). + */ + hasActive?: (workspaceId: string) => boolean; } = $props(); + // Whether at least one conversation in this workspace is currently active + // (generating). Reactive: the composition-root port reads the app store's + // reactive tab set + lifecycle statuses, so this re-derives on change. + const active = $derived(hasActive?.(ws.id) ?? false); + // ── Title: double-click to rename inline ────────────────────────────────── let editingTitle = $state(false); let titleDraft = $state(""); @@ -144,6 +157,13 @@ ondblclick={startEditTitle}>{ws.title}</span > {/if} + {#if active} + <span + class="loading loading-dots loading-xs shrink-0 text-primary" + aria-label="Workspace has active conversations" + title="A conversation in this workspace is generating"></span + > + {/if} <span class="font-mono text-xs opacity-50">/{ws.id}</span> <button type="button" diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts index f3ed1e7..28aed88 100644 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ b/src/features/workspaces/ui/WorkspaceCard.test.ts @@ -143,6 +143,63 @@ describe("WorkspaceCard", () => { expect(onNavigate).toHaveBeenCalledWith("/my-ws"); }); + // ── Active indicator (loading dots) ────────────────────────────────────── + + it("shows no loading-dots when no hasActive port is given", () => { + const store = fakeStore() as unknown as WorkspaceStore; + const { container } = render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, + }); + expect(container.querySelector(".loading-dots")).toBeNull(); + }); + + it("shows no loading-dots when hasActive returns false", () => { + const store = fakeStore() as unknown as WorkspaceStore; + const { container } = render(WorkspaceCard, { + props: { + ws: fakeEntry(), + store, + onNavigate: vi.fn(), + computers: [], + hasActive: () => false, + }, + }); + expect(container.querySelector(".loading-dots")).toBeNull(); + }); + + it("shows loading-dots when hasActive returns true", () => { + const store = fakeStore() as unknown as WorkspaceStore; + const { container } = render(WorkspaceCard, { + props: { + ws: fakeEntry(), + store, + onNavigate: vi.fn(), + computers: [], + hasActive: () => true, + }, + }); + const dots = container.querySelector(".loading-dots"); + expect(dots).not.toBeNull(); + // Accessible label ties the indicator to the workspace-active concept. + expect(dots?.getAttribute("aria-label")).toBe("Workspace has active conversations"); + }); + + it("forwards the workspace id to hasActive", () => { + const store = fakeStore() as unknown as WorkspaceStore; + const seen: string[] = []; + render(WorkspaceCard, { + props: { + ws: fakeEntry({ id: "proj-x" }), + store, + onNavigate: vi.fn(), + computers: [], + hasActive: (id: string) => { + seen.push(id); + return false; + }, + }, + }); + expect(seen).toEqual(["proj-x"]); it("renders an outline star button for an unstarred workspace", () => { const store = fakeStore() as unknown as WorkspaceStore; render(WorkspaceCard, { diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index 02e92b4..d97eab7 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -9,10 +9,17 @@ store, onNavigate, computers, + hasActive, }: { store: WorkspaceStore; onNavigate: (path: string) => void; computers: readonly ComputerEntry[]; + /** + * Optional port forwarded to each {@link WorkspaceCard}: whether the + * workspace has at least one active (generating / queued) conversation. + * Wired by the composition root to the app store. Absent → no indicator. + */ + hasActive?: (workspaceId: string) => boolean; } = $props(); onMount(() => { @@ -89,7 +96,13 @@ {:else} <ul class="flex flex-col gap-2"> {#each store.list as ws (ws.id)} - <WorkspaceCard {ws} {store} {onNavigate} {computers} /> + <WorkspaceCard + {ws} + {store} + {onNavigate} + {computers} + {...(hasActive ? { hasActive } : {})} + /> {/each} </ul> {/if} |
