diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 18:37:09 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 18:37:09 +0900 |
| commit | dbf77ba78ff840e0ed5f6294030523fe3ab121fa (patch) | |
| tree | e768aef3edd0c126212058c3d1433355594c49be /packages/conversation-store/src | |
| parent | 6689eb51b467d8e370f31495840d88661f978168 (diff) | |
| download | dispatch-dbf77ba78ff840e0ed5f6294030523fe3ab121fa.tar.gz dispatch-dbf77ba78ff840e0ed5f6294030523fe3ab121fa.zip | |
feat(history): CR-5 windowed reads — ?limit= / ?beforeSeq= on GET /conversations/:id
Selection sinceSeq < seq < beforeSeq; newest-limit window, ascending; positive-
integer validation (400, store never sees an invalid window); 1-based gap-free
seq codified as the contractual has-older mechanism (no earliestSeq field).
transport-contract 0.9.0->0.10.0, wire 0.6.0->0.6.1 (doc-only).
conversation-store +8 tests, transport-http +20; 935 vitest + 112 bun green.
Live-verified: 6/6 probe checks OK. FE courier: frontend-history-windowing-handoff.md
Diffstat (limited to 'packages/conversation-store/src')
| -rw-r--r-- | packages/conversation-store/src/store.test.ts | 88 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.ts | 45 |
2 files changed, 132 insertions, 1 deletions
diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index 49b0e58..5b07eca 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -499,6 +499,94 @@ describe("ConversationStore", () => { }); }); +describe("ConversationStore loadSince windowing", () => { + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + // Append `count` single-chunk user messages so seq runs 1..count, gap-free. + async function seed(store: ReturnType<typeof createConversationStore>, count: number) { + const messages: ChatMessage[] = []; + for (let i = 1; i <= count; i++) { + messages.push({ role: "user", chunks: [{ type: "text", text: `m${i}` }] }); + } + await store.append("conv1", messages); + } + + it("limit returns the newest N of the selection, ascending by seq", async () => { + const store = createConversationStore(storage); + await seed(store, 5); + const chunks = await store.loadSince("conv1", 0, { limit: 2 }); + expect(chunks.map((c) => c.seq)).toEqual([4, 5]); + }); + + it("limit >= selection size returns the whole selection (exact, not truncated)", async () => { + const store = createConversationStore(storage); + await seed(store, 3); + const exactlyAll = await store.loadSince("conv1", 0, { limit: 3 }); + expect(exactlyAll.map((c) => c.seq)).toEqual([1, 2, 3]); + const overAll = await store.loadSince("conv1", 0, { limit: 99 }); + expect(overAll.map((c) => c.seq)).toEqual([1, 2, 3]); + }); + + it("beforeSeq bounds the selection exclusively (seq < beforeSeq)", async () => { + const store = createConversationStore(storage); + await seed(store, 5); + const chunks = await store.loadSince("conv1", 0, { beforeSeq: 3 }); + expect(chunks.map((c) => c.seq)).toEqual([1, 2]); + }); + + it("sinceSeq + beforeSeq combine to sinceSeq < seq < beforeSeq", async () => { + const store = createConversationStore(storage); + await seed(store, 6); + const chunks = await store.loadSince("conv1", 2, { beforeSeq: 5 }); + expect(chunks.map((c) => c.seq)).toEqual([3, 4]); + }); + + it("beforeSeq + limit: newest N below the bound, ascending (page older history in)", async () => { + const store = createConversationStore(storage); + await seed(store, 8); + const chunks = await store.loadSince("conv1", 0, { beforeSeq: 6, limit: 2 }); + expect(chunks.map((c) => c.seq)).toEqual([4, 5]); + }); + + it("empty selection returns [] (beforeSeq=1, and sinceSeq past the tail)", async () => { + const store = createConversationStore(storage); + await seed(store, 4); + expect(await store.loadSince("conv1", 0, { beforeSeq: 1 })).toEqual([]); + expect(await store.loadSince("conv1", 4, { limit: 3 })).toEqual([]); + }); + + it("non-positive / non-integer limit and beforeSeq are treated as absent", async () => { + const store = createConversationStore(storage); + await seed(store, 4); + const all = [1, 2, 3, 4]; + expect((await store.loadSince("conv1", 0, { limit: 0 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { limit: -2 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { limit: 1.5 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { beforeSeq: 0 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { beforeSeq: -3 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { beforeSeq: 2.7 })).map((c) => c.seq)).toEqual(all); + }); + + it("window omitted is identical to today's behavior (regression guard)", async () => { + const store = createConversationStore(storage); + await seed(store, 5); + const base = await store.loadSince("conv1", 1); + const withEmptyWindow = await store.loadSince("conv1", 1, {}); + // A caller whose window fields happen to be undefined (e.g. unset query + // params) — modelled as an optional-field record, not explicit `undefined` + // literals (which exactOptionalPropertyTypes rejects on the contract). + const undefinedFieldsWindow: { beforeSeq?: number; limit?: number } = {}; + const withUndefinedFields = await store.loadSince("conv1", 1, undefinedFieldsWindow); + expect(base.map((c) => c.seq)).toEqual([2, 3, 4, 5]); + expect(withEmptyWindow).toEqual(base); + expect(withUndefinedFields).toEqual(base); + }); +}); + describe("ConversationStore metrics", () => { let storage: StorageNamespace; diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 0948f64..0a42917 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -23,9 +23,33 @@ import { reconcileWithReport } from "./reconcile.js"; export interface ConversationStore { readonly append: (conversationId: string, messages: readonly ChatMessage[]) => Promise<void>; readonly load: (conversationId: string) => Promise<ChatMessage[]>; + /** + * Read the conversation's persisted chunks as a SELECTION + optional WINDOW, + * ascending by seq. The raw append-order log; NOT reconciled (a dangling + * tool-call is returned as-is — repair is a turn-path concern). + * + * - **Selection** — `sinceSeq` is an exclusive lower bound (`seq > sinceSeq`; + * omitted/`0`/non-positive/non-integer = from the start). When + * `window.beforeSeq` is given it is an exclusive upper bound + * (`seq < beforeSeq`). Together: `sinceSeq < seq < beforeSeq`. + * - **Window** — `window.limit` returns only the NEWEST `limit` chunks of the + * selection; the result STAYS ASCENDING by seq. A selection with ≤ `limit` + * chunks is returned whole (exact, not truncated). + * - **Omitted = unchanged** — `window` absent (or both its fields undefined) + * is byte-identical to the pre-windowing behavior, so existing callers that + * pass no third argument are unaffected. + * - **Garbage-in is forgiving** — a non-positive or non-integer `limit` (or + * `beforeSeq`) is treated as ABSENT (full selection); this method never + * throws on bad window input. The transport validates and 400s upstream. + * + * Seq numbering is 1-based and gap-free, so a client derives "older chunks + * exist" purely from the oldest returned `seq > 1`; there is deliberately no + * `earliestSeq`/high-water-mark API. + */ readonly loadSince: ( conversationId: string, sinceSeq?: number, + window?: { readonly beforeSeq?: number; readonly limit?: number }, ) => Promise<readonly StoredChunk[]>; readonly appendMetrics: (conversationId: string, metrics: TurnMetrics) => Promise<void>; readonly loadMetrics: (conversationId: string) => Promise<readonly TurnMetrics[]>; @@ -37,6 +61,16 @@ export interface ConversationStore { export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); +/** + * Coerce a window bound to a positive integer, or `undefined` (= absent) for any + * non-positive / non-integer / undefined input. Keeps `loadSince` total. + */ +function positiveInt(value: number | undefined): number | undefined { + if (value === undefined) return undefined; + if (!Number.isInteger(value) || value <= 0) return undefined; + return value; +} + interface PersistedChunkEntry { readonly chunk: Chunk; readonly role: Role; @@ -118,23 +152,32 @@ export function createConversationStore( return repaired; }, - async loadSince(conversationId, sinceSeq) { + async loadSince(conversationId, sinceSeq, window) { const prefix = chunkPrefix(conversationId); const keys = await storage.keys(prefix); const sorted = [...keys].sort(); const result: StoredChunk[] = []; const minSeq = sinceSeq ?? 0; + // Forgiving: a non-positive / non-integer bound is treated as ABSENT. + const beforeSeq = positiveInt(window?.beforeSeq); + const limit = positiveInt(window?.limit); for (const key of sorted) { const seq = parseSeq(key.split(":").pop() ?? null); if (seq <= minSeq) continue; + if (beforeSeq !== undefined && seq >= beforeSeq) continue; const value = await storage.get(key); if (value === null) continue; const entry = JSON.parse(value) as PersistedChunkEntry; result.push({ seq, role: entry.role, chunk: entry.chunk }); } + // Window: keep only the NEWEST `limit` chunks, still ascending by seq. + if (limit !== undefined && result.length > limit) { + return result.slice(result.length - limit); + } + return result; }, |
