diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:13:28 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:13:31 +0900 |
| commit | 2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch) | |
| tree | 94e1923180ae38d571d34b578afecb0a18913c24 /src/features/conversation-cache | |
| parent | 80f99665034a0e510300793205c162fc7a46769f (diff) | |
| parent | 08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff) | |
| download | dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.tar.gz dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.zip | |
Merge branch 'dev' into feature/heartbeat
# Conflicts:
# src/app/App.svelte
# src/app/store.svelte.ts
# src/app/store.test.ts
# src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/conversation-cache')
| -rw-r--r-- | src/features/conversation-cache/cache.test.ts | 326 | ||||
| -rw-r--r-- | src/features/conversation-cache/cache.ts | 98 | ||||
| -rw-r--r-- | src/features/conversation-cache/index.ts | 10 | ||||
| -rw-r--r-- | src/features/conversation-cache/logic.test.ts | 248 | ||||
| -rw-r--r-- | src/features/conversation-cache/logic.ts | 82 | ||||
| -rw-r--r-- | src/features/conversation-cache/types.ts | 38 |
6 files changed, 401 insertions, 401 deletions
diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts index 89e81b8..ce1c60c 100644 --- a/src/features/conversation-cache/cache.test.ts +++ b/src/features/conversation-cache/cache.test.ts @@ -4,9 +4,9 @@ import { createConversationCache } from "./cache"; import type { ConversationCacheIndexEntry, ConversationChunkStore } from "./types"; const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({ - seq, - role, - chunk: { type: "text", text: `chunk-${seq}` }, + seq, + role, + chunk: { type: "text", text: `chunk-${seq}` }, }); /** @@ -14,184 +14,184 @@ const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => * An outermost edge: simulates the storage port without any real I/O. */ function createFakeStore(): ConversationChunkStore { - const store = new Map<string, StoredChunk[]>(); - - return { - async load(conversationId) { - return store.get(conversationId) ?? []; - }, - - async append(conversationId, chunks) { - const existing = store.get(conversationId) ?? []; - const existingSeqs = new Set(existing.map((c) => c.seq)); - const toAdd = chunks.filter((c) => !existingSeqs.has(c.seq)); - store.set( - conversationId, - [...existing, ...toAdd].sort((a, b) => a.seq - b.seq), - ); - }, - - async delete(conversationId) { - store.delete(conversationId); - }, - - async index() { - const entries: ConversationCacheIndexEntry[] = []; - for (const [id, chunks] of store) { - if (chunks.length === 0) continue; - let maxSeq = 0; - for (const c of chunks) { - if (c.seq > maxSeq) maxSeq = c.seq; - } - entries.push({ - conversationId: id, - chunkCount: chunks.length, - maxSeq, - }); - } - return entries; - }, - }; + const store = new Map<string, StoredChunk[]>(); + + return { + async load(conversationId) { + return store.get(conversationId) ?? []; + }, + + async append(conversationId, chunks) { + const existing = store.get(conversationId) ?? []; + const existingSeqs = new Set(existing.map((c) => c.seq)); + const toAdd = chunks.filter((c) => !existingSeqs.has(c.seq)); + store.set( + conversationId, + [...existing, ...toAdd].sort((a, b) => a.seq - b.seq), + ); + }, + + async delete(conversationId) { + store.delete(conversationId); + }, + + async index() { + const entries: ConversationCacheIndexEntry[] = []; + for (const [id, chunks] of store) { + if (chunks.length === 0) continue; + let maxSeq = 0; + for (const c of chunks) { + if (c.seq > maxSeq) maxSeq = c.seq; + } + entries.push({ + conversationId: id, + chunkCount: chunks.length, + maxSeq, + }); + } + return entries; + }, + }; } describe("cache.load", () => { - it("returns stored chunks", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(2)]); - const result = await cache.load("conv-1"); - expect(result).toEqual([chunk(1), chunk(2)]); - }); - - it("returns empty array for absent conversation", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - const result = await cache.load("nonexistent"); - expect(result).toEqual([]); - }); + it("returns stored chunks", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(2)]); + const result = await cache.load("conv-1"); + expect(result).toEqual([chunk(1), chunk(2)]); + }); + + it("returns empty array for absent conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + const result = await cache.load("nonexistent"); + expect(result).toEqual([]); + }); }); describe("cache.commit", () => { - it("appends only new chunks", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(2)]); - - const merged = await cache.commit("conv-1", [chunk(2), chunk(3)]); - expect(merged).toEqual([chunk(1), chunk(2), chunk(3)]); - - // Verify store has all chunks - const stored = await store.load("conv-1"); - expect(stored).toEqual([chunk(1), chunk(2), chunk(3)]); - }); - - it("returns full merged result", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - - const merged = await cache.commit("conv-1", [chunk(3), chunk(1)]); - expect(merged).toEqual([chunk(1), chunk(3)]); - }); - - it("is idempotent — re-committing same chunks is a no-op", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - - await cache.commit("conv-1", [chunk(1), chunk(2)]); - const merged = await cache.commit("conv-1", [chunk(1), chunk(2)]); - expect(merged).toEqual([chunk(1), chunk(2)]); - - const stored = await store.load("conv-1"); - expect(stored).toEqual([chunk(1), chunk(2)]); - }); + it("appends only new chunks", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(2)]); + + const merged = await cache.commit("conv-1", [chunk(2), chunk(3)]); + expect(merged).toEqual([chunk(1), chunk(2), chunk(3)]); + + // Verify store has all chunks + const stored = await store.load("conv-1"); + expect(stored).toEqual([chunk(1), chunk(2), chunk(3)]); + }); + + it("returns full merged result", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + + const merged = await cache.commit("conv-1", [chunk(3), chunk(1)]); + expect(merged).toEqual([chunk(1), chunk(3)]); + }); + + it("is idempotent — re-committing same chunks is a no-op", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + + await cache.commit("conv-1", [chunk(1), chunk(2)]); + const merged = await cache.commit("conv-1", [chunk(1), chunk(2)]); + expect(merged).toEqual([chunk(1), chunk(2)]); + + const stored = await store.load("conv-1"); + expect(stored).toEqual([chunk(1), chunk(2)]); + }); }); describe("cache.sinceSeq", () => { - it("returns max seq from cache", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(5), chunk(3)]); - expect(await cache.sinceSeq("conv-1")).toBe(5); - }); - - it("returns 0 for empty conversation", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - expect(await cache.sinceSeq("conv-1")).toBe(0); - }); + it("returns max seq from cache", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(5), chunk(3)]); + expect(await cache.sinceSeq("conv-1")).toBe(5); + }); + + it("returns 0 for empty conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + expect(await cache.sinceSeq("conv-1")).toBe(0); + }); }); describe("cache.evictIfOverBudget", () => { - it("deletes selected conversations", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store, { maxChunks: 5 }); - - await store.append("a", [chunk(1), chunk(2)]); - await store.append("b", [chunk(1), chunk(2)]); - await store.append("c", [chunk(1)]); - - // Total = 5, max = 5, under budget - const evicted = await cache.evictIfOverBudget(null); - expect(evicted).toEqual([]); - - // Add more to go over budget - await store.append("d", [chunk(1), chunk(2), chunk(3)]); - // Total = 8, max = 5, need to evict 3+ chunks - - const evicted2 = await cache.evictIfOverBudget(null); - expect(evicted2.length).toBeGreaterThan(0); - - // Verify evicted conversations are deleted - for (const id of evicted2) { - expect(await store.load(id)).toEqual([]); - } - }); - - it("never evicts the active conversation", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store, { maxChunks: 3 }); - - await store.append("active", [chunk(1), chunk(2), chunk(3)]); - await store.append("other", [chunk(1), chunk(2)]); - - // Total = 5, max = 3, need to evict 2+ chunks - const evicted = await cache.evictIfOverBudget("active"); - expect(evicted).not.toContain("active"); - expect(evicted).toContain("other"); - }); - - it("returns empty when under budget", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store, { maxChunks: 100 }); - - await store.append("a", [chunk(1)]); - await store.append("b", [chunk(1)]); - - const evicted = await cache.evictIfOverBudget(null); - expect(evicted).toEqual([]); - }); + it("deletes selected conversations", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 5 }); + + await store.append("a", [chunk(1), chunk(2)]); + await store.append("b", [chunk(1), chunk(2)]); + await store.append("c", [chunk(1)]); + + // Total = 5, max = 5, under budget + const evicted = await cache.evictIfOverBudget(null); + expect(evicted).toEqual([]); + + // Add more to go over budget + await store.append("d", [chunk(1), chunk(2), chunk(3)]); + // Total = 8, max = 5, need to evict 3+ chunks + + const evicted2 = await cache.evictIfOverBudget(null); + expect(evicted2.length).toBeGreaterThan(0); + + // Verify evicted conversations are deleted + for (const id of evicted2) { + expect(await store.load(id)).toEqual([]); + } + }); + + it("never evicts the active conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 3 }); + + await store.append("active", [chunk(1), chunk(2), chunk(3)]); + await store.append("other", [chunk(1), chunk(2)]); + + // Total = 5, max = 3, need to evict 2+ chunks + const evicted = await cache.evictIfOverBudget("active"); + expect(evicted).not.toContain("active"); + expect(evicted).toContain("other"); + }); + + it("returns empty when under budget", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 100 }); + + await store.append("a", [chunk(1)]); + await store.append("b", [chunk(1)]); + + const evicted = await cache.evictIfOverBudget(null); + expect(evicted).toEqual([]); + }); }); describe("cache.delete", () => { - it("removes the conversation from the store", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); + it("removes the conversation from the store", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(2)]); - await cache.delete("conv-1"); + await store.append("conv-1", [chunk(1), chunk(2)]); + await cache.delete("conv-1"); - const stored = await store.load("conv-1"); - expect(stored).toEqual([]); - }); + const stored = await store.load("conv-1"); + expect(stored).toEqual([]); + }); - it("then load returns []", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); + it("then load returns []", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); - await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]); - await cache.delete("conv-1"); + await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]); + await cache.delete("conv-1"); - const result = await cache.load("conv-1"); - expect(result).toEqual([]); - }); + const result = await cache.load("conv-1"); + expect(result).toEqual([]); + }); }); diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts index 3d5743a..4953944 100644 --- a/src/features/conversation-cache/cache.ts +++ b/src/features/conversation-cache/cache.ts @@ -3,31 +3,31 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; import type { ConversationChunkStore } from "./types"; export interface ConversationCache { - /** Load all cached chunks for a conversation. */ - load(conversationId: string): Promise<readonly StoredChunk[]>; + /** Load all cached chunks for a conversation. */ + load(conversationId: string): Promise<readonly StoredChunk[]>; - /** - * Load + reconcile + append new chunks. - * Returns the merged cache (the new authoritative cache for this conversation). - */ - commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>; + /** + * Load + reconcile + append new chunks. + * Returns the merged cache (the new authoritative cache for this conversation). + */ + commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>; - /** Return the `?sinceSeq=` cursor for the next incremental sync. */ - sinceSeq(conversationId: string): Promise<number>; + /** Return the `?sinceSeq=` cursor for the next incremental sync. */ + sinceSeq(conversationId: string): Promise<number>; - /** - * Evict conversations over budget. - * Returns the evicted conversationIds. - */ - evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>; + /** + * Evict conversations over budget. + * Returns the evicted conversationIds. + */ + evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>; - /** Delete all cached data for a single conversation (local forget). */ - delete(conversationId: string): Promise<void>; + /** Delete all cached data for a single conversation (local forget). */ + delete(conversationId: string): Promise<void>; } export interface ConversationCacheOptions { - /** Maximum total chunks across all conversations before eviction triggers. */ - readonly maxChunks?: number; + /** Maximum total chunks across all conversations before eviction triggers. */ + readonly maxChunks?: number; } const DEFAULT_MAX_CHUNKS = 10_000; @@ -38,41 +38,41 @@ const DEFAULT_MAX_CHUNKS = 10_000; * The ONLY impurity is the injected `store`; all logic delegates to pure functions. */ export function createConversationCache( - store: ConversationChunkStore, - opts?: ConversationCacheOptions, + store: ConversationChunkStore, + opts?: ConversationCacheOptions, ): ConversationCache { - const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS; + const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS; - return { - async load(conversationId) { - return store.load(conversationId); - }, + return { + async load(conversationId) { + return store.load(conversationId); + }, - async commit(conversationId, incoming) { - const cached = await store.load(conversationId); - const { merged, toAppend } = reconcileCache(cached, incoming); - if (toAppend.length > 0) { - await store.append(conversationId, toAppend); - } - return merged; - }, + async commit(conversationId, incoming) { + const cached = await store.load(conversationId); + const { merged, toAppend } = reconcileCache(cached, incoming); + if (toAppend.length > 0) { + await store.append(conversationId, toAppend); + } + return merged; + }, - async sinceSeq(conversationId) { - const cached = await store.load(conversationId); - return nextSinceSeq(cached); - }, + async sinceSeq(conversationId) { + const cached = await store.load(conversationId); + return nextSinceSeq(cached); + }, - async evictIfOverBudget(activeConversationId) { - const idx = await store.index(); - const toEvict = selectEvictions(idx, { maxChunks, activeConversationId }); - for (const id of toEvict) { - await store.delete(id); - } - return toEvict; - }, + async evictIfOverBudget(activeConversationId) { + const idx = await store.index(); + const toEvict = selectEvictions(idx, { maxChunks, activeConversationId }); + for (const id of toEvict) { + await store.delete(id); + } + return toEvict; + }, - async delete(conversationId) { - await store.delete(conversationId); - }, - }; + async delete(conversationId) { + await store.delete(conversationId); + }, + }; } diff --git a/src/features/conversation-cache/index.ts b/src/features/conversation-cache/index.ts index 32e32d9..25e2af7 100644 --- a/src/features/conversation-cache/index.ts +++ b/src/features/conversation-cache/index.ts @@ -2,13 +2,13 @@ export type { ConversationCache, ConversationCacheOptions } from "./cache"; export { createConversationCache } from "./cache"; export { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; export type { - ConversationCacheIndexEntry, - ConversationChunkStore, - ReconcileResult, + ConversationCacheIndexEntry, + ConversationChunkStore, + ReconcileResult, } from "./types"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "conversation-cache", - description: "IndexedDB-backed chunk cache with reconciliation", + name: "conversation-cache", + description: "IndexedDB-backed chunk cache with reconciliation", } as const; diff --git a/src/features/conversation-cache/logic.test.ts b/src/features/conversation-cache/logic.test.ts index 858460a..880c32e 100644 --- a/src/features/conversation-cache/logic.test.ts +++ b/src/features/conversation-cache/logic.test.ts @@ -4,137 +4,137 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; import type { ConversationCacheIndexEntry } from "./types"; const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({ - seq, - role, - chunk: { type: "text", text: `chunk-${seq}` }, + seq, + role, + chunk: { type: "text", text: `chunk-${seq}` }, }); describe("reconcileCache", () => { - it("merges and dedupes by seq", () => { - const cached = [chunk(1), chunk(2)]; - const incoming = [chunk(2), chunk(3)]; - const result = reconcileCache(cached, incoming); - expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]); - }); - - it("toAppend excludes already-cached seqs", () => { - const cached = [chunk(1), chunk(2)]; - const incoming = [chunk(2), chunk(3)]; - const result = reconcileCache(cached, incoming); - expect(result.toAppend).toEqual([chunk(3)]); - }); - - it("tolerates out-of-order incoming", () => { - const cached = [chunk(1)]; - const incoming = [chunk(5), chunk(3), chunk(2)]; - const result = reconcileCache(cached, incoming); - expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]); - expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]); - }); - - it("returns empty merged and toAppend when both inputs are empty", () => { - const result = reconcileCache([], []); - expect(result.merged).toEqual([]); - expect(result.toAppend).toEqual([]); - }); - - it("handles empty cached with incoming", () => { - const incoming = [chunk(3), chunk(1)]; - const result = reconcileCache([], incoming); - expect(result.merged).toEqual([chunk(1), chunk(3)]); - expect(result.toAppend).toEqual([chunk(3), chunk(1)]); - }); - - it("handles cached with empty incoming", () => { - const cached = [chunk(1), chunk(2)]; - const result = reconcileCache(cached, []); - expect(result.merged).toEqual([chunk(1), chunk(2)]); - expect(result.toAppend).toEqual([]); - }); - - it("is idempotent — re-reconciling same incoming produces same result", () => { - const cached = [chunk(1)]; - const incoming = [chunk(2), chunk(3)]; - const first = reconcileCache(cached, incoming); - const second = reconcileCache(first.merged, incoming); - expect(second.merged).toEqual(first.merged); - expect(second.toAppend).toEqual([]); - }); + it("merges and dedupes by seq", () => { + const cached = [chunk(1), chunk(2)]; + const incoming = [chunk(2), chunk(3)]; + const result = reconcileCache(cached, incoming); + expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]); + }); + + it("toAppend excludes already-cached seqs", () => { + const cached = [chunk(1), chunk(2)]; + const incoming = [chunk(2), chunk(3)]; + const result = reconcileCache(cached, incoming); + expect(result.toAppend).toEqual([chunk(3)]); + }); + + it("tolerates out-of-order incoming", () => { + const cached = [chunk(1)]; + const incoming = [chunk(5), chunk(3), chunk(2)]; + const result = reconcileCache(cached, incoming); + expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]); + expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]); + }); + + it("returns empty merged and toAppend when both inputs are empty", () => { + const result = reconcileCache([], []); + expect(result.merged).toEqual([]); + expect(result.toAppend).toEqual([]); + }); + + it("handles empty cached with incoming", () => { + const incoming = [chunk(3), chunk(1)]; + const result = reconcileCache([], incoming); + expect(result.merged).toEqual([chunk(1), chunk(3)]); + expect(result.toAppend).toEqual([chunk(3), chunk(1)]); + }); + + it("handles cached with empty incoming", () => { + const cached = [chunk(1), chunk(2)]; + const result = reconcileCache(cached, []); + expect(result.merged).toEqual([chunk(1), chunk(2)]); + expect(result.toAppend).toEqual([]); + }); + + it("is idempotent — re-reconciling same incoming produces same result", () => { + const cached = [chunk(1)]; + const incoming = [chunk(2), chunk(3)]; + const first = reconcileCache(cached, incoming); + const second = reconcileCache(first.merged, incoming); + expect(second.merged).toEqual(first.merged); + expect(second.toAppend).toEqual([]); + }); }); describe("nextSinceSeq", () => { - it("returns max seq", () => { - const cached = [chunk(1), chunk(5), chunk(3)]; - expect(nextSinceSeq(cached)).toBe(5); - }); - - it("returns 0 when empty", () => { - expect(nextSinceSeq([])).toBe(0); - }); - - it("returns single seq for single chunk", () => { - expect(nextSinceSeq([chunk(42)])).toBe(42); - }); + it("returns max seq", () => { + const cached = [chunk(1), chunk(5), chunk(3)]; + expect(nextSinceSeq(cached)).toBe(5); + }); + + it("returns 0 when empty", () => { + expect(nextSinceSeq([])).toBe(0); + }); + + it("returns single seq for single chunk", () => { + expect(nextSinceSeq([chunk(42)])).toBe(42); + }); }); describe("selectEvictions", () => { - it("never evicts the active conversation", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 }, - { conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 }, - ]; - const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" }); - expect(result).not.toContain("active"); - expect(result).toContain("other"); - }); - - it("evicts LRU until under budget", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, - { conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 }, - { conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 }, - { conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 }, - ]; - // Total = 120, max = 60, need to evict 60+ chunks - // LRU order: d(10), b(50), a(100), c(200) - const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); - expect(result).toEqual(["d", "b"]); - }); - - it("is a no-op under budget", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 }, - { conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 }, - ]; - const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null }); - expect(result).toEqual([]); - }); - - it("returns empty for empty index", () => { - const result = selectEvictions([], { maxChunks: 100, activeConversationId: null }); - expect(result).toEqual([]); - }); - - it("tie-breaks by smaller maxSeq when lastAccess is equal", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 }, - { conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 }, - { conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 }, - ]; - // Total = 90, max = 60, need to evict 30+ chunks - // All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200) - const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); - expect(result).toEqual(["b"]); - }); - - it("handles missing lastAccess (treated as 0)", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, - { conversationId: "b", chunkCount: 30, maxSeq: 30 }, - ]; - // Total = 60, max = 30, need to evict 30+ chunks - // b has no lastAccess (0), a has 100 - const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null }); - expect(result).toEqual(["b"]); - }); + it("never evicts the active conversation", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 }, + { conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 }, + ]; + const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" }); + expect(result).not.toContain("active"); + expect(result).toContain("other"); + }); + + it("evicts LRU until under budget", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, + { conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 }, + { conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 }, + { conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 }, + ]; + // Total = 120, max = 60, need to evict 60+ chunks + // LRU order: d(10), b(50), a(100), c(200) + const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); + expect(result).toEqual(["d", "b"]); + }); + + it("is a no-op under budget", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 }, + { conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 }, + ]; + const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null }); + expect(result).toEqual([]); + }); + + it("returns empty for empty index", () => { + const result = selectEvictions([], { maxChunks: 100, activeConversationId: null }); + expect(result).toEqual([]); + }); + + it("tie-breaks by smaller maxSeq when lastAccess is equal", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 }, + { conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 }, + { conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 }, + ]; + // Total = 90, max = 60, need to evict 30+ chunks + // All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200) + const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); + expect(result).toEqual(["b"]); + }); + + it("handles missing lastAccess (treated as 0)", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, + { conversationId: "b", chunkCount: 30, maxSeq: 30 }, + ]; + // Total = 60, max = 30, need to evict 30+ chunks + // b has no lastAccess (0), a has 100 + const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null }); + expect(result).toEqual(["b"]); + }); }); diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts index 4a4479e..3cb23e2 100644 --- a/src/features/conversation-cache/logic.ts +++ b/src/features/conversation-cache/logic.ts @@ -9,24 +9,24 @@ import type { ConversationCacheIndexEntry, ReconcileResult } from "./types"; * (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`. */ export function reconcileCache( - cached: readonly StoredChunk[], - incoming: readonly StoredChunk[], + cached: readonly StoredChunk[], + incoming: readonly StoredChunk[], ): ReconcileResult { - const seen = new Set<number>(); - for (const chunk of cached) { - seen.add(chunk.seq); - } + const seen = new Set<number>(); + for (const chunk of cached) { + seen.add(chunk.seq); + } - const toAppend: StoredChunk[] = []; - for (const chunk of incoming) { - if (!seen.has(chunk.seq)) { - toAppend.push(chunk); - seen.add(chunk.seq); - } - } + const toAppend: StoredChunk[] = []; + for (const chunk of incoming) { + if (!seen.has(chunk.seq)) { + toAppend.push(chunk); + seen.add(chunk.seq); + } + } - const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq); - return { merged, toAppend }; + const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq); + return { merged, toAppend }; } /** @@ -34,12 +34,12 @@ export function reconcileCache( * This is the `?sinceSeq=` cursor for the next incremental sync. */ export function nextSinceSeq(cached: readonly StoredChunk[]): number { - if (cached.length === 0) return 0; - let max = 0; - for (const chunk of cached) { - if (chunk.seq > max) max = chunk.seq; - } - return max; + if (cached.length === 0) return 0; + let max = 0; + for (const chunk of cached) { + if (chunk.seq > max) max = chunk.seq; + } + return max; } /** @@ -50,28 +50,28 @@ export function nextSinceSeq(cached: readonly StoredChunk[]): number { * Returns [] when under budget. */ export function selectEvictions( - index: readonly ConversationCacheIndexEntry[], - opts: { maxChunks: number; activeConversationId: string | null }, + index: readonly ConversationCacheIndexEntry[], + opts: { maxChunks: number; activeConversationId: string | null }, ): readonly string[] { - const totalChunks = index.reduce((sum, entry) => sum + entry.chunkCount, 0); - if (totalChunks <= opts.maxChunks) return []; + const totalChunks = index.reduce((sum, entry) => sum + entry.chunkCount, 0); + if (totalChunks <= opts.maxChunks) return []; - const candidates = index - .filter((entry) => entry.conversationId !== opts.activeConversationId) - .sort((a, b) => { - const aAccess = a.lastAccess ?? 0; - const bAccess = b.lastAccess ?? 0; - if (aAccess !== bAccess) return aAccess - bAccess; - return a.maxSeq - b.maxSeq; - }); + const candidates = index + .filter((entry) => entry.conversationId !== opts.activeConversationId) + .sort((a, b) => { + const aAccess = a.lastAccess ?? 0; + const bAccess = b.lastAccess ?? 0; + if (aAccess !== bAccess) return aAccess - bAccess; + return a.maxSeq - b.maxSeq; + }); - let remaining = totalChunks; - const evictions: string[] = []; - for (const entry of candidates) { - if (remaining <= opts.maxChunks) break; - evictions.push(entry.conversationId); - remaining -= entry.chunkCount; - } + let remaining = totalChunks; + const evictions: string[] = []; + for (const entry of candidates) { + if (remaining <= opts.maxChunks) break; + evictions.push(entry.conversationId); + remaining -= entry.chunkCount; + } - return evictions; + return evictions; } diff --git a/src/features/conversation-cache/types.ts b/src/features/conversation-cache/types.ts index 2a349cc..345ab71 100644 --- a/src/features/conversation-cache/types.ts +++ b/src/features/conversation-cache/types.ts @@ -2,10 +2,10 @@ import type { StoredChunk } from "@dispatch/wire"; /** Metadata entry for a cached conversation, used by eviction logic. */ export interface ConversationCacheIndexEntry { - readonly conversationId: string; - readonly chunkCount: number; - readonly maxSeq: number; - readonly lastAccess?: number; + readonly conversationId: string; + readonly chunkCount: number; + readonly maxSeq: number; + readonly lastAccess?: number; } /** @@ -17,26 +17,26 @@ export interface ConversationCacheIndexEntry { * All methods MUST be idempotent on `seq`: re-appending an existing seq is a no-op. */ export interface ConversationChunkStore { - /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */ - load(conversationId: string): Promise<readonly StoredChunk[]>; + /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */ + load(conversationId: string): Promise<readonly StoredChunk[]>; - /** - * Append committed chunks to a conversation's cache. - * MUST be idempotent on `seq`: re-appending an existing seq is a no-op. - */ - append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void>; + /** + * Append committed chunks to a conversation's cache. + * MUST be idempotent on `seq`: re-appending an existing seq is a no-op. + */ + append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void>; - /** Delete all cached data for a conversation. */ - delete(conversationId: string): Promise<void>; + /** Delete all cached data for a conversation. */ + delete(conversationId: string): Promise<void>; - /** Return metadata for all cached conversations (for eviction). */ - index(): Promise<readonly ConversationCacheIndexEntry[]>; + /** Return metadata for all cached conversations (for eviction). */ + index(): Promise<readonly ConversationCacheIndexEntry[]>; } /** Result of reconciling cached chunks with incoming authoritative chunks. */ export interface ReconcileResult { - /** The merged, deduplicated, seq-ordered chunk list. */ - readonly merged: readonly StoredChunk[]; - /** The subset of incoming chunks that need to be appended (not already cached). */ - readonly toAppend: readonly StoredChunk[]; + /** The merged, deduplicated, seq-ordered chunk list. */ + readonly merged: readonly StoredChunk[]; + /** The subset of incoming chunks that need to be appended (not already cached). */ + readonly toAppend: readonly StoredChunk[]; } |
