diff options
Diffstat (limited to 'src/features/chat/store.test.ts')
| -rw-r--r-- | src/features/chat/store.test.ts | 308 |
1 files changed, 307 insertions, 1 deletions
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts index 71781ac..1c99e7c 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -1,7 +1,12 @@ import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire"; import { describe, expect, it, vi } from "vitest"; import { createChatStore } from "./store.svelte"; -import { createFakeCache, createFakeHistorySync, createFakeTransport } from "./test-helpers"; +import { + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, +} from "./test-helpers"; const CONV_ID = "test-conv-1"; @@ -21,11 +26,13 @@ describe("createChatStore", () => { it("folding a chat.delta updates messages", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -51,11 +58,13 @@ describe("createChatStore", () => { it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -92,11 +101,13 @@ describe("createChatStore", () => { it("send posts a chat.send with conversationId", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -114,12 +125,14 @@ describe("createChatStore", () => { it("send posts a chat.send with model when set", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, model: "openai/gpt-4", transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -134,11 +147,13 @@ describe("createChatStore", () => { it("chat.error sets error", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -154,6 +169,7 @@ describe("createChatStore", () => { it("load hydrates from cache then syncs the tail", async () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); // Pre-populate cache @@ -166,6 +182,7 @@ describe("createChatStore", () => { conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -184,6 +201,7 @@ describe("createChatStore", () => { it("load with empty cache still syncs", async () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); historySync.returnChunks = [makeStoredChunk(1, "assistant")]; @@ -192,6 +210,7 @@ describe("createChatStore", () => { conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -206,11 +225,13 @@ describe("createChatStore", () => { it("error is cleared on successful sync", async () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -236,11 +257,13 @@ describe("createChatStore", () => { it("dispose prevents further syncs", async () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -262,6 +285,7 @@ describe("createChatStore", () => { it("overlapping syncs are guarded", async () => { const transport = createFakeTransport(); const _historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); // Make the first sync slow @@ -283,6 +307,7 @@ describe("createChatStore", () => { conversationId: CONV_ID, transport: transport.impl, historySync: slowHistorySync, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -310,11 +335,13 @@ describe("createChatStore", () => { it("handles tool-call and tool-result chunks", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -353,12 +380,14 @@ describe("createChatStore", () => { it("setModel changes the model used by the next send", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, model: "openai/gpt-4", transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -375,11 +404,13 @@ describe("createChatStore", () => { it("setModel from undefined to a model", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -396,11 +427,13 @@ describe("createChatStore", () => { it("handleDelta ignores a chat.delta for a different conversationId", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -424,11 +457,13 @@ describe("createChatStore", () => { it("handleDelta ignores a chat.error for a different conversationId", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -442,11 +477,13 @@ describe("createChatStore", () => { it("send optimistically shows the user message immediately", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -464,11 +501,13 @@ describe("createChatStore", () => { it("the optimistic user message is replaced after turn-sealed + history sync", async () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, + metricsSync: metricsSync.impl, cache: cache.impl, }); @@ -496,4 +535,271 @@ describe("createChatStore", () => { store.dispose(); }); + + it("folding usage/step-complete/done deltas exposes turnMetrics", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + expect(store.turnMetrics).toHaveLength(0); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "step-complete", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 200, + genTotalMs: 800, + }), + ); + store.handleDelta( + deltaEvent({ + type: "done", + conversationId: CONV_ID, + turnId: "t1", + reason: "end-turn", + durationMs: 1200, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + const entry = store.turnMetrics[0]; + expect(entry?.turnId).toBe("t1"); + expect(entry?.steps).toHaveLength(1); + expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId); + expect(entry?.steps[0]?.usage.inputTokens).toBe(100); + expect(entry?.steps[0]?.genTotalMs).toBe(800); + expect(entry?.total).not.toBeNull(); + expect(entry?.total?.usage.inputTokens).toBe(100); + expect(entry?.total?.usage.outputTokens).toBe(50); + expect(entry?.total?.durationMs).toBe(1200); + + store.dispose(); + }); + + it("turnMetrics entry has total: null before done (progressive turn)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "step-complete", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 200, + genTotalMs: 800, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + const entry = store.turnMetrics[0]; + expect(entry?.turnId).toBe("t1"); + expect(entry?.steps).toHaveLength(1); + expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId); + expect(entry?.total).toBeNull(); + + store.dispose(); + }); + + it("metricsSync durable result overrides live by turnId", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Live fold gives some metrics + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "done", + conversationId: CONV_ID, + turnId: "t1", + reason: "end-turn", + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50); + + // Durable sync returns different numbers for the same turnId + metricsSync.returnTurns = [ + { + turnId: "t1", + usage: { inputTokens: 200, outputTokens: 80 }, + durationMs: 500, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 200, outputTokens: 80 }, + genTotalMs: 400, + }, + ], + }, + ]; + + // Trigger metrics sync via turn-sealed + historySync.returnChunks = []; + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + await vi.waitFor(() => { + expect(metricsSync.calls).toHaveLength(1); + }); + + // Durable should now override live (syncMetrics is async, wait for it) + await vi.waitFor(() => { + expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80); + }); + + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.durationMs).toBe(500); + + store.dispose(); + }); + + it("rejected metricsSync leaves live metrics intact and does not throw", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Live fold some metrics + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "done", + conversationId: CONV_ID, + turnId: "t1", + reason: "end-turn", + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + + // Make the metrics sync reject + metricsSync.nextError = "metrics endpoint unavailable"; + + historySync.returnChunks = []; + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + await vi.waitFor(() => { + expect(metricsSync.calls).toHaveLength(1); + }); + + // Live metrics should still be intact + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50); + + // No error should have been thrown to the store + expect(store.error).toBeNull(); + + store.dispose(); + }); + + it("load calls metricsSync after history sync", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + + metricsSync.returnTurns = [ + { + turnId: "t1", + usage: { inputTokens: 300, outputTokens: 100 }, + durationMs: 900, + steps: [], + }, + ]; + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + await store.load(); + + expect(historySync.calls).toHaveLength(1); + expect(metricsSync.calls).toHaveLength(1); + expect(metricsSync.calls[0]).toBe(CONV_ID); + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300); + + store.dispose(); + }); }); |
