From d66585333ee5764700c67a81eaec015b0026f8f1 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 12 Jun 2026 19:00:29 +0900 Subject: feat(chat): consume CR-5 history windowing — server-windowed cold loads + show-earlier backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-pinned transport-contract@0.9.0->0.10.0 + wire@0.6.0->0.6.1 (reply frontend-history-windowing-handoff.md); re-mirrored both .dispatch references. - HistorySync port gains optional { limit?, beforeSeq? } (CR-5 params); the app's createHistorySync appends them to GET /conversations/:id. - COLD-cache fresh load now fetches ?sinceSeq=0&limit= — a huge conversation no longer ships whole to show 192 chunks. A warm-cache tail sync stays unwindowed (windowing a tail that outgrew the limit would leave a silent seq gap behind the cache). - hasEarlier now derives from the wire@0.6.1 CONTRACT (1-based gap-free seqs): loaded window starting above seq 1 => older history exists — covering both locally-trimmed AND server-windowed transcripts (the watermark stays as the merge floor only). - showEarlier(): local cache first; when the cache doesn't reach far enough back, backfills the missing older run via ?beforeSeq=&limit= and persists it (next page-in is local). latestSeq windowed-read caveat is satisfied structurally (tail cursor derives from the cache's max seq). - live-probe: +6 CR-5 checks (seq origin, newest-k ascending, short-chat exactness, beforeSeq paging, 400 validation x2). NOT yet run live — backend was down at commit time; run pending. - backend-handoff.md: CR-5 RESOLVED, pins/mirrors current. 602 tests green x2. --- src/core/chunks/trim.test.ts | 30 +++++++++++++++++++------ src/core/chunks/trim.ts | 52 +++++++++++++++++++++++++++----------------- 2 files changed, 55 insertions(+), 27 deletions(-) (limited to 'src/core/chunks') diff --git a/src/core/chunks/trim.test.ts b/src/core/chunks/trim.test.ts index 091b646..7914f35 100644 --- a/src/core/chunks/trim.test.ts +++ b/src/core/chunks/trim.test.ts @@ -177,28 +177,43 @@ describe("restoreEarlier", () => { expect(selectHasEarlier(restored)).toBe(true); }); - it("clears the watermark when the restore exhausts known earlier history", () => { + it("restoring down to seq 1 reaches the contractual origin (hasEarlier clears)", () => { const windowed = windowTranscript(stateWith(chunks(1, 100)), 75); // hidden: 1..25 const restored = restoreEarlier(windowed, chunks(1, 100), 64); expect(restored.committed).toHaveLength(100); expect(restored.committed[0]?.seq).toBe(1); - expect(restored.hiddenBeforeSeq).toBe(0); + expect(restored.hiddenBeforeSeq).toBe(1); // floor at the origin — inert expect(restored.hiddenThinkingCount).toBe(0); expect(selectHasEarlier(restored)).toBe(false); }); - it("clears the watermark when nothing is actually below it", () => { + it("is the identity when nothing older is known locally (server may still hold more)", () => { const windowed = windowTranscript(stateWith(chunks(50, 200)), 75); const restored = restoreEarlier(windowed, [], 64); - expect(restored.hiddenBeforeSeq).toBe(0); - expect(restored.committed).toEqual(windowed.committed); + expect(restored).toBe(windowed); + // seqs are 1-based gap-free: window starts at 126 ⇒ older chunks DO exist. + expect(selectHasEarlier(restored)).toBe(true); }); - it("is the identity when nothing is hidden", () => { + it("is the identity when the window already starts at seq 1", () => { const state = stateWith(chunks(1, 10)); expect(restoreEarlier(state, chunks(1, 10), 5)).toBe(state); }); + it("works on a server-windowed transcript (no local watermark)", () => { + // A cold-cache fresh load with `?limit=` commits a suffix (seq 809..1000) + // with hiddenBeforeSeq still 0 — hasEarlier derives from seq > 1, and a + // backfilled run merges below it. + const state = stateWith(chunks(809, 1000)); + expect(state.hiddenBeforeSeq).toBe(0); + expect(selectHasEarlier(state)).toBe(true); + const restored = restoreEarlier(state, chunks(745, 808), 64); + expect(restored.committed[0]?.seq).toBe(745); + expect(restored.committed).toHaveLength(192 + 64); + expect(restored.hiddenBeforeSeq).toBe(745); + expect(selectHasEarlier(restored)).toBe(true); + }); + it("decrements the hidden thinking count by the restored thinking chunks", () => { const committed = [chunk(1, "thinking"), chunk(2), chunk(3, "thinking"), ...chunks(4, 12)]; const trimmed = trimTranscript(stateWith(committed), 10); // drops 3: seqs 1..3 (2 thinking) @@ -213,6 +228,7 @@ describe("restoreEarlier", () => { const trimmed = trimTranscript(stateWith(original), 100); const restored = restoreEarlier(trimmed, original, 1000); expect(restored.committed).toEqual(original); - expect(restored.hiddenBeforeSeq).toBe(0); + expect(restored.hiddenBeforeSeq).toBe(1); + expect(selectHasEarlier(restored)).toBe(false); }); }); diff --git a/src/core/chunks/trim.ts b/src/core/chunks/trim.ts index 1733027..94065b3 100644 --- a/src/core/chunks/trim.ts +++ b/src/core/chunks/trim.ts @@ -110,40 +110,52 @@ export function windowTranscript(state: TranscriptState, maxCommitted: number): } /** - * Page earlier (unloaded) history back in — the "Show earlier messages" action. + * The oldest LOADED seq — the start of the transcript's loaded window. Usually + * `committed[0].seq`; falls back to the watermark when a trim emptied the + * committed list (all-provisional overflow). 0 = window start unknown/origin. + */ +function oldestLoadedSeq(state: TranscriptState): number { + return state.committed[0]?.seq ?? state.hiddenBeforeSeq; +} + +/** + * Page earlier history back in — the "Show earlier messages" action. * - * `earlier` must be ALL locally-known chunks below the watermark (typically the - * full cached conversation; chunks at/above the watermark are ignored). The - * newest `count` of them are merged back in front of `committed` and the - * watermark lowers to the new oldest loaded seq — or clears to 0 when this - * restore exhausts the known earlier history (nothing left to offer). + * `earlier` is every locally-known chunk older than the loaded window + * (typically the full cached conversation, possibly extended by a CR-5 + * `?beforeSeq=` backfill; chunks at/inside the window are ignored). The newest + * `count` of them are merged back in front of `committed`, and the watermark + * follows the new window start so history merges still can't resurrect what + * remains unloaded. Identity when the window already starts at seq 1 (the + * contractual origin) or nothing older is known locally. */ export function restoreEarlier( state: TranscriptState, earlier: readonly StoredChunk[], count: number, ): TranscriptState { - if (state.hiddenBeforeSeq <= 0) return state; - const below = earlier.filter((c) => c.seq < state.hiddenBeforeSeq).sort((a, b) => a.seq - b.seq); - if (below.length === 0) { - // Nothing is actually hidden below the watermark: clear it so the - // "Show earlier" affordance disappears. - return { ...state, hiddenBeforeSeq: 0, hiddenThinkingCount: 0 }; - } + const oldest = oldestLoadedSeq(state); + if (oldest <= 1) return state; + const below = earlier.filter((c) => c.seq < oldest).sort((a, b) => a.seq - b.seq); + if (below.length === 0) return state; const keep = below.slice(-Math.max(1, count)); - const exhausted = keep.length === below.length; const firstKept = keep[0]; return { ...state, committed: [...keep, ...state.committed], - hiddenBeforeSeq: exhausted || firstKept === undefined ? 0 : firstKept.seq, - hiddenThinkingCount: exhausted - ? 0 - : Math.max(0, state.hiddenThinkingCount - countThinking(keep)), + hiddenBeforeSeq: firstKept?.seq ?? state.hiddenBeforeSeq, + hiddenThinkingCount: Math.max(0, state.hiddenThinkingCount - countThinking(keep)), }; } -/** Whether unloaded earlier history exists to offer ("Show earlier messages"). */ +/** + * Whether earlier history exists below the loaded window — drives the + * "Show earlier messages" affordance. Derived from the wire@0.6.1 CONTRACT + * that per-conversation seqs are 1-based and gap-free: a loaded window that + * starts above seq 1 means older chunks exist (locally cached or server-side), + * whether the window came from a local trim or a server-windowed (`?limit=`) + * fresh load. + */ export function selectHasEarlier(state: TranscriptState): boolean { - return state.hiddenBeforeSeq > 0; + return oldestLoadedSeq(state) > 1; } -- cgit v1.2.3