summaryrefslogtreecommitdiffhomepage
path: root/src/core
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 19:00:29 +0900
committerAdam Malczewski <[email protected]>2026-06-12 19:00:29 +0900
commitd66585333ee5764700c67a81eaec015b0026f8f1 (patch)
tree6e1ac455c2ecbf3c442fce9f73fdaed8fb71fade /src/core
parent1764e3e5dff836255d121a933dd92542368346f9 (diff)
downloaddispatch-web-d66585333ee5764700c67a81eaec015b0026f8f1.tar.gz
dispatch-web-d66585333ee5764700c67a81eaec015b0026f8f1.zip
feat(chat): consume CR-5 history windowing — server-windowed cold loads + show-earlier backfill
Re-pinned [email protected]>0.10.0 + [email protected]>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=<floor(0.75xL)> — 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 [email protected] 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=<oldestKnown>&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.
Diffstat (limited to 'src/core')
-rw-r--r--src/core/chunks/trim.test.ts30
-rw-r--r--src/core/chunks/trim.ts52
2 files changed, 55 insertions, 27 deletions
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 [email protected] 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;
}