diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 13:57:41 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 13:57:41 +0900 |
| commit | d27d97bb3aa0c13f4032bab54703ebb9e1c84c81 (patch) | |
| tree | b5bcfed65be5a4d27a7bbe6b46e338dcd489c2e0 /packages/frontend | |
| parent | 3671b82cc624117476e30b95eaf7d2bc3b34ae28 (diff) | |
| parent | c1439ea8c677ddfd11c219de39c3e77c7e297a9b (diff) | |
| download | dispatch-d27d97bb3aa0c13f4032bab54703ebb9e1c84c81.tar.gz dispatch-d27d97bb3aa0c13f4032bab54703ebb9e1c84c81.zip | |
Merge branch 'dev' into u3/agent-effort-level
# Conflicts:
# packages/api/tests/agent-manager.test.ts
Diffstat (limited to 'packages/frontend')
| -rw-r--r-- | packages/frontend/src/App.svelte | 57 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ContextWindowPanel.svelte | 85 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 11 | ||||
| -rw-r--r-- | packages/frontend/src/lib/context-window.ts | 37 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 17 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 7 | ||||
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 333 | ||||
| -rw-r--r-- | packages/frontend/tests/context-window.test.ts | 84 |
8 files changed, 630 insertions, 1 deletions
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index 3f1c500..ecfdc9f 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -75,6 +75,62 @@ $effect(() => { } }); +// ─── Context-window max lookup ───────────────────────────────── +// Resolve the active model's MAXIMUM context window from models.dev (via the +// API), so the Context Window sidebar view can show `current / max`. Cached +// per provider+model; `null` when unknown (the view then hides the +// denominator/percentage). Only Claude-backed providers are resolvable. +let contextLimit = $state<number | null>(null); +const contextLimitCache = new Map<string, number | null>(); + +$effect(() => { + const tab = tabStore.activeTab; + const keyId = tab?.keyId ?? null; + const modelId = tab?.modelId ?? null; + const provider = keyId ? (modelsData.keys.find((k) => k.id === keyId)?.provider ?? null) : null; + + if (!provider || !modelId) { + contextLimit = null; + return; + } + + const cacheKey = `${provider}/${modelId}`; + if (contextLimitCache.has(cacheKey)) { + contextLimit = contextLimitCache.get(cacheKey) ?? null; + return; + } + + // Clear immediately so a slow/failed fetch can't leave the PREVIOUS + // model's max on screen (which would render this model's tokens against + // the wrong denominator). The view degrades to a bare token count until + // the fetch resolves. + contextLimit = null; + + // Fetch is async; guard against a stale response overwriting a newer + // selection by re-checking the active tab's key/model on resolve. + void (async () => { + try { + const res = await fetch( + `${config.apiBase}/models/context-limit?provider=${encodeURIComponent(provider)}&modelId=${encodeURIComponent(modelId)}`, + ); + if (!res.ok) return; + const data = (await res.json()) as { contextLimit?: number | null }; + const limit = data.contextLimit ?? null; + contextLimitCache.set(cacheKey, limit); + const current = tabStore.activeTab; + const currentProvider = current?.keyId + ? (modelsData.keys.find((k) => k.id === current.keyId)?.provider ?? null) + : null; + if (currentProvider === provider && current?.modelId === modelId) { + contextLimit = limit; + } + } catch { + // Leave contextLimit as-is on network error; view falls back to + // showing the bare token count. + } + })(); +}); + onMount(() => { // Apply persisted theme (or the shared DEFAULT_THEME if nothing is // stored) so the first paint matches what the Settings panel will @@ -138,6 +194,7 @@ onMount(() => { tasks={tabStore.activeTab?.tasks ?? []} cacheStats={tabStore.activeTab?.cacheStats ?? null} cacheTabTitle={tabStore.activeTab?.title ?? null} + {contextLimit} permissionLog={tabStore.permissionLog} apiBase={config.apiBase} activeKeyId={tabStore.activeTab?.keyId ?? null} diff --git a/packages/frontend/src/lib/components/ContextWindowPanel.svelte b/packages/frontend/src/lib/components/ContextWindowPanel.svelte new file mode 100644 index 0000000..6c7de05 --- /dev/null +++ b/packages/frontend/src/lib/components/ContextWindowPanel.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> +import { computeContextUsage } from "../context-window.js"; +import type { CacheStats } from "../types.js"; + +const { + cacheStats = null, + contextLimit = null, + tabTitle = null, + modelId = null, +}: { + cacheStats?: CacheStats | null; + contextLimit?: number | null; + tabTitle?: string | null; + modelId?: string | null; +} = $props(); + +const usage = $derived(computeContextUsage(cacheStats, contextLimit)); + +// As the window fills, escalate color: calm → warning → danger. +function fillClass(pct: number): string { + if (pct >= 90) return "progress-error"; + if (pct >= 70) return "progress-warning"; + return "progress-success"; +} + +function fmt(n: number): string { + return n.toLocaleString(); +} + +const hasUsage = $derived((cacheStats?.last ?? null) !== null); +</script> + +<div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto"> + {#if !hasUsage} + <p class="text-xs text-base-content/50"> + No context data yet. Send a message — the current context size appears + here after the first response. + </p> + {:else} + <div class="bg-base-200 rounded-lg p-2"> + <div class="flex items-center gap-1.5 mb-2"> + <span class="text-xs font-semibold">Context Window</span> + {#if tabTitle} + <span class="badge badge-xs badge-ghost">{tabTitle}</span> + {/if} + {#if usage.percent !== null} + <span class="badge badge-xs ml-auto">{usage.percent.toFixed(2)}%</span> + {/if} + </div> + + <!-- Headline: current / max (or just current when max is unknown) --> + <div class="flex items-baseline gap-1.5"> + <span class="text-lg font-mono font-semibold">{fmt(usage.current)}</span> + {#if usage.max !== null} + <span class="text-xs text-base-content/50 font-mono">/ {fmt(usage.max)}</span> + {/if} + <span class="text-xs text-base-content/40 ml-1">tokens</span> + </div> + + {#if usage.percent !== null} + <progress + class="progress w-full h-2 mt-1.5 {fillClass(usage.percent)}" + value={usage.percent} + max="100" + ></progress> + {:else} + <p class="text-xs text-base-content/40 mt-1.5"> + Max context size unknown for this model. + </p> + {/if} + + {#if modelId} + <div class="text-xs text-base-content/40 mt-1.5 truncate" title={modelId}> + {modelId} + </div> + {/if} + </div> + + <p class="text-xs text-base-content/40"> + Current context = the most recent request's prompt + output (what the + model actually held in its window that turn). Grows as the conversation + gets longer. Resets on reload. + </p> + {/if} +</div> diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index 3372396..519f411 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -4,6 +4,7 @@ import type { CacheStats, KeyInfo, LogEntry, TaskItem } from "../types.js"; import CacheRatePanel from "./CacheRatePanel.svelte"; import ClaudeReset from "./ClaudeReset.svelte"; import ConfigPanel from "./ConfigPanel.svelte"; +import ContextWindowPanel from "./ContextWindowPanel.svelte"; import DebugPanel from "./DebugPanel.svelte"; import KeyUsage from "./KeyUsage.svelte"; import ModelSelector from "./ModelSelector.svelte"; @@ -27,6 +28,7 @@ const { tasks = [], cacheStats = null, cacheTabTitle = null, + contextLimit = null, permissionLog = [], apiBase = "", activeKeyId = null, @@ -47,6 +49,7 @@ const { tasks?: TaskItem[]; cacheStats?: CacheStats | null; cacheTabTitle?: string | null; + contextLimit?: number | null; permissionLog?: LogEntry[]; apiBase?: string; activeKeyId?: string | null; @@ -89,6 +92,7 @@ const viewOptions = [ "Chat Settings", "Key Usage", "Cache Rate", + "Context Window", "Claude Reset", "Model Status", "Tasks", @@ -170,6 +174,13 @@ function contentClass(_selected: string): string { <KeyUsage {keys} {apiBase} /> {:else if panel.selected === "Cache Rate"} <CacheRatePanel {cacheStats} tabTitle={cacheTabTitle} /> + {:else if panel.selected === "Context Window"} + <ContextWindowPanel + {cacheStats} + {contextLimit} + tabTitle={cacheTabTitle} + modelId={activeModelId} + /> {:else if panel.selected === "Claude Reset"} <ClaudeReset {apiBase} /> {:else if panel.selected === "Model Status"} diff --git a/packages/frontend/src/lib/context-window.ts b/packages/frontend/src/lib/context-window.ts new file mode 100644 index 0000000..c4321f8 --- /dev/null +++ b/packages/frontend/src/lib/context-window.ts @@ -0,0 +1,37 @@ +import type { CacheStats } from "./types.js"; + +/** + * Context-window occupancy for the current tab/model. + * + * `current` is the size of the model's context on the MOST RECENT request — + * the last turn's full prompt (`inputTokens`, which already includes cached + * tokens for Anthropic) plus what the model generated that turn + * (`outputTokens`). This mirrors how opencode derives context fullness from + * the last assistant message, and reflects what actually occupies the model's + * window — NOT the session-cumulative totals shown by the Cache Rate view. + * + * `max` is the model's maximum context window from models.dev (or `null` when + * unknown). `percent` is `current / max * 100` clamped to [0, 100] (unrounded; + * the UI decides the displayed precision), or `null` when + * `max` is unknown — in which case the UI shows the bare token count with no + * denominator or progress bar. + */ +export interface ContextUsage { + current: number; + max: number | null; + percent: number | null; +} + +export function computeContextUsage( + cacheStats: CacheStats | null | undefined, + contextLimit: number | null | undefined, +): ContextUsage { + const last = cacheStats?.last ?? null; + const current = last ? last.inputTokens + last.outputTokens : 0; + const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; + // Precise (unrounded) percentage clamped to [0, 100]; the UI formats the + // decimal places. Kept unrounded so small contexts against huge windows + // (e.g. a few thousand tokens vs. 1,000,000) still read non-zero. + const percent = max ? Math.max(0, Math.min(100, (current / max) * 100)) : null; + return { current, max, percent }; +} diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index d3061c3..ec718bd 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -761,6 +761,13 @@ export function createTabStore() { keyId?: string | null; modelId?: string | null; parentTabId?: string | null; + // Backend usage aggregate (GET /tabs). Structurally identical to + // CacheStats, so it seeds `cacheStats` directly on reload. This is the + // initial seed (hydrate runs only when tabs.length === 0, i.e. a true + // reload); thereafter `turn-sealed` REPLACES cacheStats with the same + // aggregate each turn, keeping the live accumulator reconciled to the DB + // truth. Neither path ADDS to live events, so there is no double-count. + usageStats?: CacheStats | null; }> = []; try { const res = await fetch(`${config.apiBase}/tabs`); @@ -849,6 +856,7 @@ export function createTabStore() { chunkLimit: appSettings.chunkLimit, oldestLoadedSeq: win.oldestSeq, totalChunks: win.total, + cacheStats: row.usageStats ?? undefined, }; }); @@ -933,6 +941,15 @@ export function createTabStore() { // tail into the sealed chunk log (refetch real seqs), preserving any // newer in-flight turn. Deferred while scrolled up. reconcileSealedTurn(tabId, event.turnId); + // Reconcile cacheStats to the DB source-of-truth carried on the event. + // REPLACE (not add): the aggregate already includes every persisted + // usage row for this tab, so this both lands the just-sealed turn's + // usage AND self-heals any live overshoot (e.g. a rate-limited + // fallback attempt streamed usage live but was discarded server-side). + // `usageStats === undefined` (older backend) leaves cacheStats as-is. + if (event.usageStats !== undefined) { + updateTab(tabId, { cacheStats: event.usageStats ?? undefined }); + } break; } case "statuses": { diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 285b4d2..173f68c 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -140,7 +140,12 @@ export type AgentEvent = | { type: "turn-start"; turnId: string } // Fires after the turn settled AND its chunks were persisted (after the DB // write, post status:idle). Triggers the frontend's reconcile-from-DB. - | { type: "turn-sealed"; turnId: string } + // `usageStats` carries the tab's authoritative usage aggregate (read after the + // usage rows were persisted); the store REPLACES `cacheStats` with it, + // reconciling the live accumulator to the DB truth (self-heals the live + // overshoot from a discarded rate-limited fallback attempt). null ⇒ no usage + // rows; absent ⇒ leave cacheStats untouched. + | { type: "turn-sealed"; turnId: string; usageStats?: CacheStats | null } // Sent on every WS (re)connect: a snapshot of every tab the backend is // currently tracking and its live status. The frontend uses this to // detect desync after a reconnect (e.g. bun --watch restart killed the diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index 33a9f69..c0763cd 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -71,6 +71,7 @@ beforeEach(() => { ); }); +import { computeContextUsage } from "../src/lib/context-window.js"; import { appSettings } from "../src/lib/settings.svelte.js"; import { createTabStore } from "../src/lib/tabs.svelte.js"; import type { Chunk, PermissionPrompt } from "../src/lib/types.js"; @@ -1005,6 +1006,257 @@ describe("hydrateFromBackend", () => { expect(tC?.renderGroups.length).toBe(0); expect(tC?.agentStatus).toBe("idle"); }); + + // ─── usage persistence: seed cacheStats from the backend aggregate ── + it("seeds cacheStats from a tab's usageStats on hydrate (reload persistence)", async () => { + const usageStats = { + inputTokens: 2200, + outputTokens: 100, + cacheReadTokens: 1000, + cacheWriteTokens: 1000, + requests: 2, + last: { inputTokens: 1200, outputTokens: 60, cacheReadTokens: 1000, cacheWriteTokens: 100 }, + }; + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tabs: [ + { + id: "tu", + title: "Has usage", + keyId: null, + modelId: null, + parentTabId: null, + usageStats, + }, + { + id: "tn", + title: "No usage", + keyId: null, + modelId: null, + parentTabId: null, + usageStats: null, + }, + ], + }), + }); + } + if (url.endsWith("/status")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) }); + } + if (url.split("?")[0]?.endsWith("/tabs/tu/chunks")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }), + }); + } + if (url.split("?")[0]?.endsWith("/tabs/tn/chunks")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }), + }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(2); + // Tab with persisted usage → cacheStats seeded directly from the aggregate. + expect(store.tabs.find((t) => t.id === "tu")?.cacheStats).toEqual(usageStats); + // Tab with null usageStats → cacheStats stays undefined (no usage yet). + expect(store.tabs.find((t) => t.id === "tn")?.cacheStats).toBeUndefined(); + }); + + it("does not re-seed or double-count cacheStats on a statuses reconnect after hydrate", async () => { + const usageStats = { + inputTokens: 1000, + outputTokens: 40, + cacheReadTokens: 0, + cacheWriteTokens: 900, + requests: 1, + last: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 }, + }; + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tabs: [ + { + id: "tr", + title: "Reconnect", + keyId: null, + modelId: null, + parentTabId: null, + usageStats, + }, + ], + }), + }); + } + if (url.endsWith("/status")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) }); + } + if (url.split("?")[0]?.endsWith("/tabs/tr/chunks")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }), + }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + + const store = createTabStore(); + await store.hydrateFromBackend(); + expect(store.tabs.find((t) => t.id === "tr")?.cacheStats).toEqual(usageStats); + + // A WS reconnect snapshot must NOT touch cacheStats (in-session live + // `usage` events own the running totals; the aggregate seed is reload-only). + store.handleEvent({ type: "statuses", statuses: { tr: { status: "idle" } } }); + expect(store.tabs.find((t) => t.id === "tr")?.cacheStats).toEqual(usageStats); + }); + + it("keeps accumulating live usage events after a hydrate seed", async () => { + const usageStats = { + inputTokens: 1000, + outputTokens: 40, + cacheReadTokens: 0, + cacheWriteTokens: 900, + requests: 1, + last: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 }, + }; + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tabs: [ + { + id: "tl", + title: "Live after hydrate", + keyId: null, + modelId: null, + parentTabId: null, + usageStats, + }, + ], + }), + }); + } + if (url.endsWith("/status")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) }); + } + if (url.split("?")[0]?.endsWith("/tabs/tl/chunks")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }), + }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + + const store = createTabStore(); + await store.hydrateFromBackend(); + + // A new in-session usage event folds ON TOP of the seeded aggregate. + store.handleEvent({ + type: "usage", + tabId: "tl", + usage: { inputTokens: 200, outputTokens: 10, cacheReadTokens: 150, cacheWriteTokens: 0 }, + }); + const stats = store.tabs.find((t) => t.id === "tl")?.cacheStats; + expect(stats?.requests).toBe(2); + expect(stats?.inputTokens).toBe(1200); + expect(stats?.outputTokens).toBe(50); + expect(stats?.cacheReadTokens).toBe(150); + expect(stats?.cacheWriteTokens).toBe(900); + expect(stats?.last).toEqual({ + inputTokens: 200, + outputTokens: 10, + cacheReadTokens: 150, + cacheWriteTokens: 0, + }); + }); + + // Cross-feature contract (Context Window view, branch u2): the panel derives + // current context size from `cacheStats.last` via computeContextUsage. This + // test proves persistence restores that field on hydrate, so the view shows a + // real "x / max" immediately after a reload on a NEW DEVICE — not "No context + // data yet". Guards the contract so neither side can silently break it. + it("restores cacheStats.last on hydrate so the Context Window view has data after reload", async () => { + const usageStats = { + inputTokens: 90000, + outputTokens: 3000, + cacheReadTokens: 40000, + cacheWriteTokens: 5000, + requests: 3, + // Most recent request's snapshot — the numerator the view reads. + last: { inputTokens: 47000, outputTokens: 1200, cacheReadTokens: 30000, cacheWriteTokens: 0 }, + }; + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tabs: [ + { + id: "tc", + title: "Context after reload", + keyId: null, + modelId: null, + parentTabId: null, + usageStats, + }, + ], + }), + }); + } + if (url.endsWith("/status")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) }); + } + if (url.split("?")[0]?.endsWith("/tabs/tc/chunks")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }), + }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + + const store = createTabStore(); + await store.hydrateFromBackend(); + + // What App.svelte passes into ContextWindowPanel: the active tab's cacheStats + // plus the model's max (re-resolved from models.dev on load — here 200k). + const cacheStats = store.tabs.find((t) => t.id === "tc")?.cacheStats ?? null; + expect(cacheStats?.last).not.toBeNull(); + + const usage = computeContextUsage(cacheStats, 200000); + // current = last.inputTokens + last.outputTokens (47000 + 1200), NOT the + // cumulative session totals (which would double-count history). + expect(usage.current).toBe(48200); + expect(usage.max).toBe(200000); + expect(usage.percent).toBeCloseTo(24.1, 5); + }); }); // ─── statuses WS event with the wider TabStatusSnapshot shape ─── @@ -1120,6 +1372,87 @@ describe("tabStore — cache rate (usage events)", () => { }); expect(store.tabs[0]?.cacheStats).toBeUndefined(); }); + + it("turn-sealed REPLACES cacheStats with the carried DB aggregate (reconcile to truth)", async () => { + const { store, tabId } = await setupStoreWithTab(); + // Live events accumulate during the turn. + store.handleEvent({ + type: "usage", + tabId, + usage: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 }, + }); + expect(store.tabs.find((t) => t.id === tabId)?.cacheStats?.inputTokens).toBe(1000); + + // turn-sealed carries the authoritative aggregate → cacheStats is REPLACED. + const aggregate = { + inputTokens: 1000, + outputTokens: 40, + cacheReadTokens: 0, + cacheWriteTokens: 900, + requests: 1, + last: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 }, + }; + store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId, usageStats: aggregate }); + expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toEqual(aggregate); + }); + + it("turn-sealed self-heals a live overshoot from a discarded fallback attempt", async () => { + const { store, tabId } = await setupStoreWithTab(); + // Attempt 1 streamed usage live (overshoot), then rate-limited & discarded + // server-side; attempt 2's usage also streamed live. Live = sum of BOTH. + store.handleEvent({ + type: "usage", + tabId, + usage: { inputTokens: 999, outputTokens: 9, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }); + store.handleEvent({ + type: "usage", + tabId, + usage: { inputTokens: 222, outputTokens: 22, cacheReadTokens: 100, cacheWriteTokens: 5 }, + }); + const overshoot = store.tabs.find((t) => t.id === tabId)?.cacheStats; + expect(overshoot?.requests).toBe(2); + expect(overshoot?.inputTokens).toBe(1221); // inflated: includes discarded attempt + + // The DB only persisted attempt 2 (the survivor). turn-sealed reconciles. + const persisted = { + inputTokens: 222, + outputTokens: 22, + cacheReadTokens: 100, + cacheWriteTokens: 5, + requests: 1, + last: { inputTokens: 222, outputTokens: 22, cacheReadTokens: 100, cacheWriteTokens: 5 }, + }; + store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId, usageStats: persisted }); + // Overshoot healed: cacheStats now matches the DB truth exactly. + expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toEqual(persisted); + }); + + it("turn-sealed without usageStats leaves cacheStats untouched (back-compat)", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ + type: "usage", + tabId, + usage: { inputTokens: 500, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }); + const before = store.tabs.find((t) => t.id === tabId)?.cacheStats; + // Older backend: turn-sealed carries no usageStats field. + store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId }); + expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toEqual(before); + }); + + it("turn-sealed with usageStats: null clears cacheStats", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ + type: "usage", + tabId, + usage: { inputTokens: 500, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }); + expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toBeDefined(); + // A null aggregate (no persisted usage rows) explicitly clears live stats. + store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId, usageStats: null }); + expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toBeUndefined(); + }); }); // ─── chunk-native store: eviction, pagination, reconcile ──────── diff --git a/packages/frontend/tests/context-window.test.ts b/packages/frontend/tests/context-window.test.ts new file mode 100644 index 0000000..bb64ed5 --- /dev/null +++ b/packages/frontend/tests/context-window.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { computeContextUsage } from "../src/lib/context-window.js"; +import type { CacheStats } from "../src/lib/types.js"; + +function stats(last: CacheStats["last"]): CacheStats { + return { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + requests: last ? 1 : 0, + last, + }; +} + +describe("computeContextUsage", () => { + it("derives current context from the LAST request's input + output", () => { + const usage = computeContextUsage( + stats({ + inputTokens: 47000, + outputTokens: 1200, + cacheReadTokens: 40000, + cacheWriteTokens: 0, + }), + 200000, + ); + // 47000 + 1200 — NOT the cumulative totals, and cache tokens are already + // inside inputTokens (not re-added). + expect(usage.current).toBe(48200); + expect(usage.max).toBe(200000); + expect(usage.percent).toBeCloseTo(24.1, 5); // 48200 / 200000 * 100, unrounded + }); + + it("returns max=null and percent=null when the limit is unknown", () => { + const usage = computeContextUsage( + stats({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }), + null, + ); + expect(usage.current).toBe(100); + expect(usage.max).toBeNull(); + expect(usage.percent).toBeNull(); + }); + + it("treats a non-positive limit as unknown", () => { + const usage = computeContextUsage( + stats({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }), + 0, + ); + expect(usage.max).toBeNull(); + expect(usage.percent).toBeNull(); + }); + + it("reports zero usage when no request has completed yet", () => { + expect(computeContextUsage(null, 200000)).toEqual({ + current: 0, + max: 200000, + percent: 0, + }); + expect(computeContextUsage(stats(null), 200000)).toEqual({ + current: 0, + max: 200000, + percent: 0, + }); + }); + + it("clamps percent to 100 when context overflows the window", () => { + const usage = computeContextUsage( + stats({ inputTokens: 250000, outputTokens: 5000, cacheReadTokens: 0, cacheWriteTokens: 0 }), + 200000, + ); + expect(usage.current).toBe(255000); + expect(usage.percent).toBe(100); + }); + + it("keeps an unrounded percent so the UI can show 2 decimals", () => { + const usage = computeContextUsage( + stats({ inputTokens: 3690, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }), + 1000000, + ); + // 3690 / 1,000,000 * 100 = 0.369 → displayed as "0.37%" (toFixed(2)). + expect(usage.percent).toBeCloseTo(0.369, 6); + expect((usage.percent as number).toFixed(2)).toBe("0.37"); + }); +}); |
