@librechat/agents 3.3.13 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/cjs/graphs/Graph.cjs +10 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/llm/anthropic/index.cjs +35 -206
  4. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  5. package/dist/cjs/llm/bedrock/index.cjs +121 -241
  6. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  7. package/dist/cjs/llm/google/index.cjs +19 -12
  8. package/dist/cjs/llm/google/index.cjs.map +1 -1
  9. package/dist/cjs/llm/mistral/index.cjs +26 -0
  10. package/dist/cjs/llm/mistral/index.cjs.map +1 -0
  11. package/dist/cjs/llm/openai/index.cjs +82 -80
  12. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  13. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  14. package/dist/cjs/llm/providers.cjs +19 -19
  15. package/dist/cjs/llm/providers.cjs.map +1 -1
  16. package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
  17. package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
  18. package/dist/cjs/llm/stream/smoother.cjs +369 -0
  19. package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
  20. package/dist/cjs/llm/vertexai/index.cjs +13 -1
  21. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  22. package/dist/cjs/main.cjs +18 -10
  23. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +34 -2
  24. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  25. package/dist/cjs/utils/tokens.cjs +76 -29
  26. package/dist/cjs/utils/tokens.cjs.map +1 -1
  27. package/dist/esm/graphs/Graph.mjs +10 -0
  28. package/dist/esm/graphs/Graph.mjs.map +1 -1
  29. package/dist/esm/llm/anthropic/index.mjs +34 -205
  30. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  31. package/dist/esm/llm/bedrock/index.mjs +120 -240
  32. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  33. package/dist/esm/llm/google/index.mjs +19 -12
  34. package/dist/esm/llm/google/index.mjs.map +1 -1
  35. package/dist/esm/llm/mistral/index.mjs +26 -0
  36. package/dist/esm/llm/mistral/index.mjs.map +1 -0
  37. package/dist/esm/llm/openai/index.mjs +82 -80
  38. package/dist/esm/llm/openai/index.mjs.map +1 -1
  39. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  40. package/dist/esm/llm/providers.mjs +3 -3
  41. package/dist/esm/llm/providers.mjs.map +1 -1
  42. package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
  43. package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
  44. package/dist/esm/llm/stream/smoother.mjs +365 -0
  45. package/dist/esm/llm/stream/smoother.mjs.map +1 -0
  46. package/dist/esm/llm/vertexai/index.mjs +13 -1
  47. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  48. package/dist/esm/main.mjs +4 -2
  49. package/dist/esm/tools/subagent/SubagentExecutor.mjs +34 -2
  50. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  51. package/dist/esm/utils/tokens.mjs +76 -30
  52. package/dist/esm/utils/tokens.mjs.map +1 -1
  53. package/dist/types/graphs/Graph.d.ts +3 -0
  54. package/dist/types/index.d.ts +3 -0
  55. package/dist/types/llm/google/index.d.ts +2 -0
  56. package/dist/types/llm/mistral/index.d.ts +11 -0
  57. package/dist/types/llm/openai/index.d.ts +20 -4
  58. package/dist/types/llm/openrouter/index.d.ts +4 -1
  59. package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
  60. package/dist/types/llm/stream/smoother.d.ts +95 -0
  61. package/dist/types/llm/vertexai/index.d.ts +2 -0
  62. package/dist/types/tools/subagent/SubagentExecutor.d.ts +3 -0
  63. package/dist/types/types/llm.d.ts +21 -14
  64. package/dist/types/utils/tokens.d.ts +10 -0
  65. package/package.json +2 -2
  66. package/src/graphs/Graph.ts +11 -0
  67. package/src/index.ts +8 -0
  68. package/src/llm/anthropic/index.ts +85 -354
  69. package/src/llm/bedrock/index.ts +174 -390
  70. package/src/llm/bedrock/llm.spec.ts +2 -0
  71. package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
  72. package/src/llm/custom-chat-models.smoke.test.ts +16 -1
  73. package/src/llm/google/index.ts +17 -18
  74. package/src/llm/google/streamSmoothing.test.ts +121 -0
  75. package/src/llm/mistral/index.ts +33 -0
  76. package/src/llm/mistral/streamSmoothing.test.ts +97 -0
  77. package/src/llm/openai/deepseek.test.ts +56 -0
  78. package/src/llm/openai/index.ts +119 -126
  79. package/src/llm/openrouter/index.ts +4 -1
  80. package/src/llm/providers.ts +3 -3
  81. package/src/llm/stream/chunkAdapters.test.ts +202 -0
  82. package/src/llm/stream/chunkAdapters.ts +317 -0
  83. package/src/llm/stream/reassembly.test.ts +241 -0
  84. package/src/llm/stream/smoother.bench.test.ts +155 -0
  85. package/src/llm/stream/smoother.test.ts +519 -0
  86. package/src/llm/stream/smoother.ts +574 -0
  87. package/src/llm/vertexai/index.ts +17 -1
  88. package/src/llm/vertexai/streamSmoothing.test.ts +109 -0
  89. package/src/specs/subagent.test.ts +44 -0
  90. package/src/specs/tokens.test.ts +159 -14
  91. package/src/tools/__tests__/SubagentExecutor.test.ts +48 -1
  92. package/src/tools/subagent/SubagentExecutor.ts +69 -5
  93. package/src/types/llm.ts +53 -36
  94. package/src/utils/tokens.ts +115 -30
@@ -0,0 +1,519 @@
1
+ import {
2
+ smoothStream,
3
+ resolveStreamDelay,
4
+ STREAM_ABORT_MESSAGE,
5
+ STREAM_CHUNK_MIN_SIZE,
6
+ DEFAULT_STREAM_DELAY,
7
+ PRODUCER_CLOSE_GRACE_MS,
8
+ MAX_SMOOTH_ITEM_SEGMENT_CHARS,
9
+ STREAM_BOUNDARY_LOOKAHEAD_CHARS,
10
+ findStreamChunkBoundary,
11
+ computeAdaptivePieceSize,
12
+ SMOOTH_TARGET_LATENCY_MS,
13
+ MAX_STREAM_QUEUE_TEXT_CHARS,
14
+ } from './smoother';
15
+ import type { SmoothItem, SmoothPiece } from './smoother';
16
+
17
+ type Emitted = SmoothPiece & { tag: string; at: number };
18
+
19
+ function makeItem(
20
+ tag: string,
21
+ text: string,
22
+ smooth: boolean,
23
+ atomic?: boolean
24
+ ): SmoothItem<Emitted> {
25
+ return {
26
+ text,
27
+ smooth,
28
+ atomic,
29
+ emit: (piece) => ({ ...piece, tag, at: Date.now() }),
30
+ };
31
+ }
32
+
33
+ async function* arraySource<T>(
34
+ items: SmoothItem<T>[],
35
+ onYield?: (index: number) => void
36
+ ): AsyncGenerator<SmoothItem<T>> {
37
+ for (let i = 0; i < items.length; i++) {
38
+ onYield?.(i);
39
+ yield items[i];
40
+ }
41
+ }
42
+
43
+ async function collect(
44
+ stream: AsyncGenerator<Emitted>
45
+ ): Promise<Emitted[]> {
46
+ const out: Emitted[] = [];
47
+ for await (const piece of stream) {
48
+ out.push(piece);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ const sleep = (ms: number): Promise<void> =>
54
+ new Promise((resolve) => setTimeout(resolve, ms));
55
+
56
+ describe('resolveStreamDelay', () => {
57
+ it('defaults to DEFAULT_STREAM_DELAY when unset', () => {
58
+ expect(resolveStreamDelay(undefined)).toBe(DEFAULT_STREAM_DELAY);
59
+ });
60
+
61
+ it('passes explicit values through and clamps negatives to 0', () => {
62
+ expect(resolveStreamDelay(10)).toBe(10);
63
+ expect(resolveStreamDelay(0)).toBe(0);
64
+ expect(resolveStreamDelay(-5)).toBe(0);
65
+ });
66
+
67
+ it('normalizes non-finite values to the default', () => {
68
+ expect(resolveStreamDelay(Number.NaN)).toBe(DEFAULT_STREAM_DELAY);
69
+ expect(resolveStreamDelay(Number.POSITIVE_INFINITY)).toBe(
70
+ DEFAULT_STREAM_DELAY
71
+ );
72
+ });
73
+ });
74
+
75
+ describe('computeAdaptivePieceSize', () => {
76
+ it('floors at STREAM_CHUNK_MIN_SIZE for small backlogs', () => {
77
+ expect(computeAdaptivePieceSize(0, 25)).toBe(STREAM_CHUNK_MIN_SIZE);
78
+ expect(computeAdaptivePieceSize(1, 25)).toBe(STREAM_CHUNK_MIN_SIZE);
79
+ expect(computeAdaptivePieceSize(39, 25)).toBe(STREAM_CHUNK_MIN_SIZE);
80
+ });
81
+
82
+ it('scales proportionally with backlog', () => {
83
+ expect(computeAdaptivePieceSize(250, 25)).toBe(25);
84
+ expect(computeAdaptivePieceSize(2500, 25)).toBe(250);
85
+ expect(computeAdaptivePieceSize(1000, 10)).toBe(40);
86
+ });
87
+
88
+ it('returns the full backlog when the tick is non-positive', () => {
89
+ expect(computeAdaptivePieceSize(500, 0)).toBe(500);
90
+ });
91
+
92
+ it('holds steady-state backlog near arrival_rate x target latency', () => {
93
+ const tick = 25;
94
+ const arrivalPerTick = 100;
95
+ let backlog = 0;
96
+ const observed: number[] = [];
97
+ for (let i = 0; i < 200; i++) {
98
+ backlog += arrivalPerTick;
99
+ backlog -= Math.min(backlog, computeAdaptivePieceSize(backlog, tick));
100
+ observed.push(backlog);
101
+ }
102
+ const expected = (arrivalPerTick * SMOOTH_TARGET_LATENCY_MS) / tick;
103
+ const tail = observed.slice(-20);
104
+ for (const value of tail) {
105
+ expect(value).toBeGreaterThan(expected * 0.8);
106
+ expect(value).toBeLessThan(expected * 1.2);
107
+ }
108
+ });
109
+
110
+ it('drains a stopped stream geometrically', () => {
111
+ const tick = 25;
112
+ const perTickTicks = Math.ceil(SMOOTH_TARGET_LATENCY_MS / tick);
113
+ let backlog = 1000;
114
+ let ticks = 0;
115
+ const snapshots: number[] = [];
116
+ while (backlog > 0 && ticks < 100) {
117
+ backlog -= Math.min(backlog, computeAdaptivePieceSize(backlog, tick));
118
+ ticks++;
119
+ snapshots.push(backlog);
120
+ }
121
+ expect(backlog).toBe(0);
122
+ expect(snapshots[perTickTicks - 1]).toBeLessThan(1000 * 0.4);
123
+ expect(ticks).toBeLessThan(50);
124
+ });
125
+ });
126
+
127
+ describe('findStreamChunkBoundary', () => {
128
+ it('extends to the next boundary character inclusive', () => {
129
+ expect(findStreamChunkBoundary('hello world', 4)).toBe(6);
130
+ });
131
+
132
+ it('returns full length when no boundary follows within the lookahead', () => {
133
+ expect(findStreamChunkBoundary('hello', 4)).toBe(5);
134
+ expect(findStreamChunkBoundary('hi', 4)).toBe(2);
135
+ });
136
+
137
+ it('hard-cuts boundary-free runs at the lookahead limit', () => {
138
+ const unbroken = 'a'.repeat(10000);
139
+ expect(findStreamChunkBoundary(unbroken, 4096)).toBe(
140
+ 4096 + STREAM_BOUNDARY_LOOKAHEAD_CHARS
141
+ );
142
+ });
143
+ });
144
+
145
+ describe('smoothStream', () => {
146
+ it('splits a large smooth item into word-boundary pieces at cadence', async () => {
147
+ const text =
148
+ 'The quick brown fox jumps over the lazy dog and keeps on running through the field. ';
149
+ const pieces = await collect(
150
+ smoothStream({
151
+ source: arraySource([makeItem('a', text, true)]),
152
+ delayMs: 5,
153
+ })
154
+ );
155
+
156
+ expect(pieces.length).toBeGreaterThan(1);
157
+ expect(pieces.map((p) => p.text).join('')).toBe(text);
158
+ expect(pieces[0].isFirst).toBe(true);
159
+ expect(pieces[pieces.length - 1].isLast).toBe(true);
160
+ for (const piece of pieces.slice(1, -1)) {
161
+ expect(piece.isFirst).toBe(false);
162
+ expect(piece.isLast).toBe(false);
163
+ }
164
+ });
165
+
166
+ it('preserves strict FIFO order across smooth and passthrough items', async () => {
167
+ const items = [
168
+ makeItem('text1', 'hello world again and again ', true),
169
+ makeItem('tool', '', false),
170
+ makeItem('text2', 'more visible text here now ', true),
171
+ makeItem('usage', '', false),
172
+ ];
173
+ const pieces = await collect(
174
+ smoothStream({ source: arraySource(items), delayMs: 3 })
175
+ );
176
+
177
+ const tagRuns = pieces.map((p) => p.tag);
178
+ const collapsed = tagRuns.filter((tag, i) => tagRuns[i - 1] !== tag);
179
+ expect(collapsed).toEqual(['text1', 'tool', 'text2', 'usage']);
180
+ });
181
+
182
+ it('emits the first visible piece without delay (TTFT)', async () => {
183
+ const start = Date.now();
184
+ const stream = smoothStream({
185
+ source: arraySource([makeItem('a', 'hello world and more text ', true)]),
186
+ delayMs: 50,
187
+ });
188
+ const first = await stream.next();
189
+ expect(first.done).toBe(false);
190
+ expect(Date.now() - start).toBeLessThan(40);
191
+ await stream.return(undefined);
192
+ });
193
+
194
+ it('never delays passthrough items even between paced text', async () => {
195
+ const items = [
196
+ makeItem('text1', 'some visible words streaming along nicely ', true),
197
+ makeItem('seal', '', false),
198
+ ];
199
+ const pieces = await collect(
200
+ smoothStream({ source: arraySource(items), delayMs: 20 })
201
+ );
202
+ const sealPiece = pieces.find((p) => p.tag === 'seal');
203
+ const lastText = pieces.filter((p) => p.tag === 'text1').pop();
204
+ expect(sealPiece).toBeDefined();
205
+ expect(lastText).toBeDefined();
206
+ expect((sealPiece as Emitted).at - (lastText as Emitted).at).toBeLessThan(
207
+ 15
208
+ );
209
+ });
210
+
211
+ it('paces atomic items whole without splitting', async () => {
212
+ const text = 'this text carries logprobs and must never be split apart ';
213
+ const pieces = await collect(
214
+ smoothStream({
215
+ source: arraySource([
216
+ makeItem('lead', 'lead text goes first here ', true),
217
+ makeItem('logprobs', text, true, true),
218
+ ]),
219
+ delayMs: 3,
220
+ })
221
+ );
222
+ const atomicPieces = pieces.filter((p) => p.tag === 'logprobs');
223
+ expect(atomicPieces).toHaveLength(1);
224
+ expect(atomicPieces[0].text).toBe(text);
225
+ expect(atomicPieces[0].isFirst).toBe(true);
226
+ expect(atomicPieces[0].isLast).toBe(true);
227
+ });
228
+
229
+ it('marks exactly one isFirst piece per item for usage dedup', async () => {
230
+ const pieces = await collect(
231
+ smoothStream({
232
+ source: arraySource([
233
+ makeItem('a', 'first item with several words to split ', true),
234
+ makeItem('b', 'second item also has words to split ', true),
235
+ ]),
236
+ delayMs: 2,
237
+ })
238
+ );
239
+ for (const tag of ['a', 'b']) {
240
+ const tagged = pieces.filter((p) => p.tag === tag);
241
+ expect(tagged.filter((p) => p.isFirst)).toHaveLength(1);
242
+ expect(tagged[0].isFirst).toBe(true);
243
+ }
244
+ });
245
+
246
+ it('passes everything through unsplit and undelayed when delayMs is 0', async () => {
247
+ const text = 'a very long text that would normally be split into pieces ';
248
+ const start = Date.now();
249
+ const pieces = await collect(
250
+ smoothStream({
251
+ source: arraySource([
252
+ makeItem('a', text, true),
253
+ makeItem('b', '', false),
254
+ ]),
255
+ delayMs: 0,
256
+ })
257
+ );
258
+ expect(pieces).toHaveLength(2);
259
+ expect(pieces[0].text).toBe(text);
260
+ expect(pieces[0].isFirst).toBe(true);
261
+ expect(pieces[0].isLast).toBe(true);
262
+ expect(Date.now() - start).toBeLessThan(50);
263
+ });
264
+
265
+ it('coalesces token-sized items so backlog drains within the target window', async () => {
266
+ const tick = 10;
267
+ const items = Array.from({ length: 400 }, (_, i) =>
268
+ makeItem(`t${i}`, i % 5 === 4 ? 'x ' : 'x', true)
269
+ );
270
+ const expected = items.map((i) => i.text).join('');
271
+
272
+ const start = Date.now();
273
+ const pieces = await collect(
274
+ smoothStream({ source: arraySource(items), delayMs: tick })
275
+ );
276
+ const elapsed = Date.now() - start;
277
+
278
+ expect(pieces.map((p) => p.text).join('')).toBe(expected);
279
+ expect(elapsed).toBeLessThan(2000);
280
+ const tags = pieces.map((p) => p.tag);
281
+ expect(tags).toEqual([...tags].sort((a, b) => Number(a.slice(1)) - Number(b.slice(1))));
282
+ });
283
+
284
+ it('rethrows a producer failure even when the rejection value is nullish', async () => {
285
+ async function* failingSource(): AsyncGenerator<SmoothItem<Emitted>> {
286
+ yield makeItem('a', 'some text ', true);
287
+ throw undefined;
288
+ }
289
+
290
+ await expect(
291
+ collect(smoothStream({ source: failingSource(), delayMs: 1 }))
292
+ ).rejects.toThrow('Stream producer failed.');
293
+ });
294
+
295
+ it('stays fully lazy when smoothing is disabled', async () => {
296
+ const yielded: number[] = [];
297
+ const items = [
298
+ makeItem('a', 'first ', true),
299
+ makeItem('b', 'second ', true),
300
+ makeItem('c', 'third ', true),
301
+ ];
302
+ const stream = smoothStream({
303
+ source: arraySource(items, (i) => yielded.push(i)),
304
+ delayMs: 0,
305
+ });
306
+
307
+ await stream.next();
308
+ expect(yielded).toEqual([0]);
309
+ await stream.return(undefined);
310
+ expect(yielded).toEqual([0]);
311
+ });
312
+
313
+ it('segments an oversized item so pieces reassemble with exactly one isFirst/isLast', async () => {
314
+ const bigText = 'lorem ipsum dolor sit amet '.repeat(400);
315
+ const pieces = await collect(
316
+ smoothStream({
317
+ source: arraySource([makeItem('big', bigText, true)]),
318
+ delayMs: 1,
319
+ })
320
+ );
321
+
322
+ expect(pieces.map((p) => p.text).join('')).toBe(bigText);
323
+ expect(pieces.filter((p) => p.isFirst)).toHaveLength(1);
324
+ expect(pieces.filter((p) => p.isLast)).toHaveLength(1);
325
+ expect(pieces[0].isFirst).toBe(true);
326
+ expect(pieces[pieces.length - 1].isLast).toBe(true);
327
+ });
328
+
329
+ it('bounds pieces of boundary-free runs at the segment cap plus lookahead', async () => {
330
+ const unbroken = 'x'.repeat(20000);
331
+ const pieces = await collect(
332
+ smoothStream({
333
+ source: arraySource([makeItem('blob', unbroken, true)]),
334
+ delayMs: 1,
335
+ })
336
+ );
337
+
338
+ expect(pieces.map((p) => p.text).join('')).toBe(unbroken);
339
+ const maxPiece = Math.max(...pieces.map((p) => p.text.length));
340
+ expect(maxPiece).toBeLessThanOrEqual(
341
+ MAX_SMOOTH_ITEM_SEGMENT_CHARS + STREAM_BOUNDARY_LOOKAHEAD_CHARS
342
+ );
343
+ });
344
+
345
+ it('skips empty-text smooth items entirely', async () => {
346
+ const pieces = await collect(
347
+ smoothStream({
348
+ source: arraySource([
349
+ makeItem('empty', '', true),
350
+ makeItem('real', 'actual text ', true),
351
+ ]),
352
+ delayMs: 3,
353
+ })
354
+ );
355
+ expect(pieces.every((p) => p.tag === 'real')).toBe(true);
356
+ expect(pieces.length).toBeGreaterThan(0);
357
+ });
358
+
359
+ it('wakes a consumer parked on an empty queue when the signal aborts', async () => {
360
+ const controller = new AbortController();
361
+ const abortUpstream = jest.fn();
362
+ async function* stalledSource(): AsyncGenerator<SmoothItem<Emitted>> {
363
+ yield makeItem('a', 'first text ', true);
364
+ await new Promise<void>(() => undefined);
365
+ }
366
+
367
+ const stream = smoothStream({
368
+ source: stalledSource(),
369
+ delayMs: 5,
370
+ signal: controller.signal,
371
+ abortUpstream,
372
+ });
373
+
374
+ let next = await stream.next();
375
+ while (next.done !== true && (next.value as Emitted).isLast !== true) {
376
+ next = await stream.next();
377
+ }
378
+
379
+ const start = Date.now();
380
+ const parked = stream.next();
381
+ setTimeout(() => controller.abort(), 20);
382
+
383
+ await expect(parked).rejects.toThrow(STREAM_ABORT_MESSAGE);
384
+ expect(Date.now() - start).toBeLessThan(PRODUCER_CLOSE_GRACE_MS + 1500);
385
+ expect(abortUpstream).toHaveBeenCalled();
386
+ });
387
+
388
+ it('completes an early break promptly even when the source is stalled', async () => {
389
+ async function* stalledSource(): AsyncGenerator<SmoothItem<Emitted>> {
390
+ yield makeItem('a', 'first text ', true);
391
+ await new Promise<void>(() => undefined);
392
+ }
393
+
394
+ const stream = smoothStream({ source: stalledSource(), delayMs: 5 });
395
+ await stream.next();
396
+
397
+ const start = Date.now();
398
+ const closed = (async (): Promise<boolean> => {
399
+ await stream.return(undefined);
400
+ return true;
401
+ })();
402
+ const result = await Promise.race([
403
+ closed,
404
+ sleep(PRODUCER_CLOSE_GRACE_MS + 2000).then(() => false),
405
+ ]);
406
+ expect(result).toBe(true);
407
+ expect(Date.now() - start).toBeLessThan(PRODUCER_CLOSE_GRACE_MS + 1500);
408
+ });
409
+
410
+ it('throws the canonical abort message when aborted during the pacing sleep', async () => {
411
+ const controller = new AbortController();
412
+ const abortUpstream = jest.fn();
413
+ const stream = smoothStream({
414
+ source: arraySource([
415
+ makeItem('a', 'plenty of text to keep the consumer pacing along ', true),
416
+ ]),
417
+ delayMs: 50,
418
+ signal: controller.signal,
419
+ abortUpstream,
420
+ });
421
+
422
+ const first = await stream.next();
423
+ expect(first.done).toBe(false);
424
+ setTimeout(() => controller.abort(), 10);
425
+
426
+ await expect(async () => {
427
+ await collect(stream as AsyncGenerator<Emitted>);
428
+ }).rejects.toThrow(STREAM_ABORT_MESSAGE);
429
+ expect(abortUpstream).toHaveBeenCalled();
430
+ });
431
+
432
+ it('parks the producer at the text capacity and resumes during consumer pacing', async () => {
433
+ const bigText = 'word '.repeat(
434
+ Math.ceil(MAX_STREAM_QUEUE_TEXT_CHARS / 5) + 200
435
+ );
436
+ const yielded: number[] = [];
437
+ const items = [
438
+ makeItem('a', bigText, true),
439
+ makeItem('b', bigText, true),
440
+ makeItem('c', 'tail text ', true),
441
+ ];
442
+
443
+ const stream = smoothStream({
444
+ source: arraySource(items, (i) => yielded.push(i)),
445
+ delayMs: 1,
446
+ });
447
+
448
+ const first = await stream.next();
449
+ expect(first.done).toBe(false);
450
+ /** Segmented admission parks the producer mid-way through the first
451
+ * oversized item — later items are not read ahead past the text cap. */
452
+ expect(yielded).toContain(0);
453
+ expect(yielded).not.toContain(2);
454
+
455
+ const rest = await collect(stream as AsyncGenerator<Emitted>);
456
+ expect(yielded).toContain(1);
457
+ expect(yielded).toContain(2);
458
+ const all = [first.value as Emitted, ...rest];
459
+ expect(all.map((p) => p.text).join('')).toBe(bigText + bigText + 'tail text ');
460
+ });
461
+
462
+ it('closes a parked producer promptly after an early consumer break', async () => {
463
+ const bigText = 'word '.repeat(MAX_STREAM_QUEUE_TEXT_CHARS);
464
+ let sourceFinallyRan = false;
465
+ async function* source(): AsyncGenerator<SmoothItem<Emitted>> {
466
+ try {
467
+ yield makeItem('a', bigText, true);
468
+ yield makeItem('b', bigText, true);
469
+ yield makeItem('c', bigText, true);
470
+ } finally {
471
+ sourceFinallyRan = true;
472
+ }
473
+ }
474
+
475
+ const abortUpstream = jest.fn();
476
+ const stream = smoothStream({
477
+ source: source(),
478
+ delayMs: 25,
479
+ abortUpstream,
480
+ });
481
+
482
+ await stream.next();
483
+ await stream.next();
484
+
485
+ const closed = (async (): Promise<boolean> => {
486
+ await stream.return(undefined);
487
+ return true;
488
+ })();
489
+ const result = await Promise.race([
490
+ closed,
491
+ sleep(1000).then(() => false),
492
+ ]);
493
+ expect(result).toBe(true);
494
+ expect(abortUpstream).toHaveBeenCalled();
495
+ expect(sourceFinallyRan).toBe(true);
496
+ });
497
+
498
+ it('keeps render lag bounded near the target latency under a fast big-chunk producer', async () => {
499
+ const tick = 5;
500
+ const chunk = 'lorem ipsum dolor sit amet consectetur adipiscing elit '.repeat(2);
501
+ let producerDoneAt = 0;
502
+ async function* fastSource(): AsyncGenerator<SmoothItem<Emitted>> {
503
+ for (let i = 0; i < 12; i++) {
504
+ yield makeItem(`c${i}`, chunk, true);
505
+ await sleep(10);
506
+ }
507
+ producerDoneAt = Date.now();
508
+ }
509
+
510
+ const pieces = await collect(
511
+ smoothStream({ source: fastSource(), delayMs: tick })
512
+ );
513
+ const renderDoneAt = pieces[pieces.length - 1].at;
514
+ const lag = renderDoneAt - producerDoneAt;
515
+
516
+ expect(pieces.map((p) => p.text).join('')).toBe(chunk.repeat(12));
517
+ expect(lag).toBeLessThan(SMOOTH_TARGET_LATENCY_MS * 2.5);
518
+ });
519
+ });