1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
|
import type { StoredChunk } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import { applyHistory, initialState } from "./reducer";
import {
DEFAULT_CHAT_LIMIT,
initialWindowSize,
MAX_CHAT_LIMIT,
MIN_CHAT_LIMIT,
normalizeChatLimit,
restoreEarlier,
selectHasEarlier,
trimTranscript,
unloadCount,
windowTranscript,
} from "./trim";
import type { TranscriptState } from "./types";
function chunk(seq: number, type: "text" | "thinking" = "text"): StoredChunk {
return { seq, role: "assistant", chunk: { type, text: `c${seq}` } };
}
function chunks(from: number, to: number): StoredChunk[] {
const out: StoredChunk[] = [];
for (let seq = from; seq <= to; seq++) out.push(chunk(seq));
return out;
}
function stateWith(committed: readonly StoredChunk[]): TranscriptState {
return { ...initialState(), committed };
}
describe("normalizeChatLimit", () => {
it("defaults non-numeric / NaN / missing values", () => {
expect(normalizeChatLimit(undefined)).toBe(DEFAULT_CHAT_LIMIT);
expect(normalizeChatLimit(null)).toBe(DEFAULT_CHAT_LIMIT);
expect(normalizeChatLimit("100")).toBe(DEFAULT_CHAT_LIMIT);
expect(normalizeChatLimit(Number.NaN)).toBe(DEFAULT_CHAT_LIMIT);
expect(normalizeChatLimit(Number.POSITIVE_INFINITY)).toBe(DEFAULT_CHAT_LIMIT);
});
it("floors and clamps numeric values", () => {
expect(normalizeChatLimit(100.9)).toBe(100);
expect(normalizeChatLimit(0)).toBe(MIN_CHAT_LIMIT);
expect(normalizeChatLimit(-5)).toBe(MIN_CHAT_LIMIT);
expect(normalizeChatLimit(10_000_000)).toBe(MAX_CHAT_LIMIT);
expect(normalizeChatLimit(256)).toBe(256);
});
});
describe("unloadCount / initialWindowSize", () => {
it("unload is a quarter of the limit, rounded up", () => {
expect(unloadCount(100)).toBe(25);
expect(unloadCount(256)).toBe(64);
expect(unloadCount(10)).toBe(3);
});
it("initial window is 75% of the limit, rounded down", () => {
expect(initialWindowSize(100)).toBe(75);
expect(initialWindowSize(256)).toBe(192);
expect(initialWindowSize(1)).toBe(1); // never below 1
});
});
describe("trimTranscript", () => {
it("is the identity at or under the limit", () => {
const at = stateWith(chunks(1, 100));
expect(trimTranscript(at, 100)).toBe(at);
const under = stateWith(chunks(1, 99));
expect(trimTranscript(under, 100)).toBe(under);
});
it("unloads exactly a quarter when the limit is first exceeded (100 → 101 drops 25)", () => {
const state = stateWith(chunks(1, 101));
const next = trimTranscript(state, 100);
expect(next.committed).toHaveLength(76);
expect(next.committed[0]?.seq).toBe(26);
expect(next.hiddenBeforeSeq).toBe(26);
});
it("unloads multiple quarters when trimming was deferred far past the limit", () => {
const state = stateWith(chunks(1, 130));
const next = trimTranscript(state, 100);
// 130 → needs 2 quarters (25 each) to get to ≤ 100 → 80 remain.
expect(next.committed).toHaveLength(80);
expect(next.committed[0]?.seq).toBe(51);
expect(next.hiddenBeforeSeq).toBe(51);
});
it("counts provisional + accumulating toward the limit (drops committed first)", () => {
const base = stateWith(chunks(1, 98));
const state: TranscriptState = {
...base,
provisional: [
{ role: "user", chunk: { type: "text", text: "q" } },
{ role: "assistant", chunk: { type: "text", text: "a" } },
],
accumulating: { kind: "text", text: "stream" },
};
// 98 + 2 + 1 = 101 > 100 → drop 25 committed.
const next = trimTranscript(state, 100);
expect(next.committed).toHaveLength(73);
expect(next.provisional).toHaveLength(2);
expect(next.accumulating).not.toBeNull();
});
it("drops oldest provisional when committed is exhausted", () => {
const base = stateWith(chunks(1, 2));
const provisional = Array.from({ length: 20 }, (_, i) => ({
role: "assistant" as const,
chunk: { type: "text" as const, text: `p${i}` },
}));
const state: TranscriptState = { ...base, provisional };
// 2 + 20 = 22 > 10. quarter = 3. Drop 2 committed, then drop
// ceil((20-10)/3)*3 = 12 provisional → 8 remain.
const next = trimTranscript(state, 10);
expect(next.committed).toHaveLength(0);
expect(next.provisional).toHaveLength(8);
expect(next.hiddenBeforeSeq).toBe(3);
});
it("accumulates the hidden thinking count for stable render keys", () => {
const committed = [chunk(1, "thinking"), ...chunks(2, 9), chunk(10, "thinking"), chunk(11)];
const state = stateWith(committed);
const next = trimTranscript(state, 10); // 11 > 10 → drop ceil(10/4)=3 oldest
expect(next.committed[0]?.seq).toBe(4);
expect(next.hiddenThinkingCount).toBe(1);
});
it("ignores a nonsensical limit", () => {
const state = stateWith(chunks(1, 50));
expect(trimTranscript(state, 0)).toBe(state);
expect(trimTranscript(state, Number.NaN)).toBe(state);
});
});
describe("windowTranscript", () => {
it("keeps only the newest maxCommitted chunks and sets the watermark", () => {
const state = stateWith(chunks(1, 1000));
const next = windowTranscript(state, 75);
expect(next.committed).toHaveLength(75);
expect(next.committed[0]?.seq).toBe(926);
expect(next.hiddenBeforeSeq).toBe(926);
expect(selectHasEarlier(next)).toBe(true);
});
it("is the identity within the window", () => {
const state = stateWith(chunks(1, 50));
expect(windowTranscript(state, 75)).toBe(state);
expect(selectHasEarlier(state)).toBe(false);
});
});
describe("applyHistory respects the watermark", () => {
it("does not resurrect chunks below hiddenBeforeSeq on a full-cache merge", () => {
const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100);
expect(trimmed.hiddenBeforeSeq).toBe(26);
// A later sync merges the FULL cache (seqs 1..101) — the unloaded prefix must stay out.
const merged = applyHistory(trimmed, chunks(1, 101));
expect(merged.committed[0]?.seq).toBe(26);
expect(merged.committed).toHaveLength(76);
});
it("still merges the tail above the watermark", () => {
const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100);
const merged = applyHistory(trimmed, chunks(100, 110));
expect(merged.committed[merged.committed.length - 1]?.seq).toBe(110);
expect(merged.committed[0]?.seq).toBe(26);
});
});
describe("restoreEarlier", () => {
it("pages the newest `count` earlier chunks back in and lowers the watermark", () => {
const windowed = windowTranscript(stateWith(chunks(1, 1000)), 75); // loaded 926..1000
const restored = restoreEarlier(windowed, chunks(1, 1000), 64);
expect(restored.committed[0]?.seq).toBe(862);
expect(restored.committed).toHaveLength(75 + 64);
expect(restored.hiddenBeforeSeq).toBe(862);
expect(selectHasEarlier(restored)).toBe(true);
});
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(1); // floor at the origin — inert
expect(restored.hiddenThinkingCount).toBe(0);
expect(selectHasEarlier(restored)).toBe(false);
});
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).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 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)
expect(trimmed.hiddenThinkingCount).toBe(2);
const restored = restoreEarlier(trimmed, committed, 2); // restores seqs 2..3 (1 thinking)
expect(restored.hiddenBeforeSeq).toBe(2);
expect(restored.hiddenThinkingCount).toBe(1);
});
it("round-trips with trim: trim → restore-all yields the original committed list", () => {
const original = chunks(1, 101);
const trimmed = trimTranscript(stateWith(original), 100);
const restored = restoreEarlier(trimmed, original, 1000);
expect(restored.committed).toEqual(original);
expect(restored.hiddenBeforeSeq).toBe(1);
expect(selectHasEarlier(restored)).toBe(false);
});
});
|