diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 13:08:36 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 13:08:36 +0900 |
| commit | b734eb96bf0af267fdfbef85df51940ca0b4e8c7 (patch) | |
| tree | 3815dc9e569bb57384f53d9042288ff831f02e74 /packages/frontend | |
| parent | 3f629a8469fe483243671e1ca15582a111e96541 (diff) | |
| download | dispatch-b734eb96bf0af267fdfbef85df51940ca0b4e8c7.tar.gz dispatch-b734eb96bf0af267fdfbef85df51940ca0b4e8c7.zip | |
feat: persist per-tab token/cache usage across reload
Persist usage as invisible type:"usage" chunk rows (side channel):
- core: add "usage" ChunkType + UsageData; exclude usage rows from
getChunksForTab/getTotalChunkCount; add getUsageStatsForTab aggregate
(exported from barrel); defensive skip in groupRowsToMessages.
- api: agent-manager accumulates per-attempt usageRows and flushes them in
the same atomic appendChunks call as the turn's content (discarded on a
superseded fallback attempt). GET /tabs enriches rows with usageStats.
- frontend: hydrateFromBackend seeds cacheStats from usageStats (reload only;
no re-seed on statuses reconnect, so no double-count with live events).
Tests: core DB-backed usage persistence/aggregate; api usage-row-per-event +
fallback discard; routes GET /tabs usageStats; frontend hydrate seed +
no-double-count + live-accumulation-after-seed. 495 -> 509 passing.
Diffstat (limited to 'packages/frontend')
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 7 | ||||
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 186 |
2 files changed, 193 insertions, 0 deletions
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 3fd7e5f..54f17d9 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -755,6 +755,12 @@ 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. 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. + usageStats?: CacheStats | null; }> = []; try { const res = await fetch(`${config.apiBase}/tabs`); @@ -843,6 +849,7 @@ export function createTabStore() { chunkLimit: appSettings.chunkLimit, oldestLoadedSeq: win.oldestSeq, totalChunks: win.total, + cacheStats: row.usageStats ?? undefined, }; }); diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index 33a9f69..4e9691f 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -1005,6 +1005,192 @@ 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, + }); + }); }); // ─── statuses WS event with the wider TabStatusSnapshot shape ─── |
