diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 13:34:33 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 13:34:33 +0900 |
| commit | 48c120e5cd400b2e2b8afae0afcc7c8bc4d2ccb4 (patch) | |
| tree | 2c434aeba0db7d6ec5b87e2f7fe2c81352f0888c /packages/frontend/tests | |
| parent | b734eb96bf0af267fdfbef85df51940ca0b4e8c7 (diff) | |
| download | dispatch-48c120e5cd400b2e2b8afae0afcc7c8bc4d2ccb4.tar.gz dispatch-48c120e5cd400b2e2b8afae0afcc7c8bc4d2ccb4.zip | |
fix: reconcile live cacheStats to DB truth on turn-sealed
Addresses the live-accumulator overshoot a Gemini review surfaced: the
frontend adds every streamed usage event to cacheStats, but a rate-limited
fallback attempt's usage is discarded server-side (never persisted). Live
numbers overshot until a reload re-seeded from the DB aggregate.
Fix: turn-sealed (emitted AFTER the atomic usage-row write) now carries the
authoritative getUsageStatsForTab aggregate. The store REPLACES (not adds)
cacheStats with it every turn — landing the just-sealed turn's usage AND
self-healing any live drift, including the discarded-fallback overshoot. No
extra round-trip (piggybacks turn-sealed); idempotent in the happy path.
- core: add UsageStats type; getUsageStatsForTab returns it; turn-sealed gains
optional usageStats field.
- api: agent-manager reads getUsageStatsForTab post-flush and attaches it to
the turn-sealed emit (try/catch: omit on DB error).
- frontend: turn-sealed handler replaces cacheStats (undefined ⇒ untouched
back-compat; null ⇒ clear).
Tests: frontend reconcile/self-heal/back-compat/null-clear; api turn-sealed
carries aggregate. 509 -> 514 passing; typecheck + biome green.
Diffstat (limited to 'packages/frontend/tests')
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 81 |
1 files changed, 81 insertions, 0 deletions
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 ──────── |
