diff options
Diffstat (limited to 'packages/frontend')
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 18 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 7 | ||||
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 81 |
3 files changed, 101 insertions, 5 deletions
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 54f17d9..65e35a8 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -756,10 +756,11 @@ export function createTabStore() { modelId?: string | null; parentTabId?: string | null; // Backend usage aggregate (GET /tabs). Structurally identical to - // CacheStats, so it seeds `cacheStats` directly on reload. Seeding - // happens ONLY here (hydrate runs when tabs.length === 0, i.e. a true - // reload) — never on `statuses` reconnect or `turn-sealed` — so the - // persisted aggregate and in-session live `usage` events never overlap. + // 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 { @@ -934,6 +935,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 4e9691f..dc2783d 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -1306,6 +1306,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 ──────── |
