assistant-stream 0.3.26 → 0.3.28

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 (145) hide show
  1. package/dist/core/AssistantStream.d.ts.map +1 -1
  2. package/dist/core/AssistantStreamChunk.d.ts +3 -3
  3. package/dist/core/accumulators/AssistantMessageStream.d.ts.map +1 -1
  4. package/dist/core/accumulators/AssistantMessageStream.js +0 -1
  5. package/dist/core/accumulators/AssistantMessageStream.js.map +1 -1
  6. package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
  7. package/dist/core/accumulators/assistant-message-accumulator.js +65 -28
  8. package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
  9. package/dist/core/converters/toGenericMessages.js.map +1 -1
  10. package/dist/core/gorp/GorpStreamAccumulator.d.ts +15 -0
  11. package/dist/core/gorp/GorpStreamAccumulator.d.ts.map +1 -0
  12. package/dist/core/{object/ObjectStreamAccumulator.js → gorp/GorpStreamAccumulator.js} +11 -9
  13. package/dist/core/gorp/GorpStreamAccumulator.js.map +1 -0
  14. package/dist/core/gorp/GorpStreamDeltaTracker.d.ts +17 -0
  15. package/dist/core/gorp/GorpStreamDeltaTracker.d.ts.map +1 -0
  16. package/dist/core/gorp/GorpStreamDeltaTracker.js +38 -0
  17. package/dist/core/gorp/GorpStreamDeltaTracker.js.map +1 -0
  18. package/dist/core/gorp/GorpStreamResponse.d.ts +16 -0
  19. package/dist/core/gorp/GorpStreamResponse.d.ts.map +1 -0
  20. package/dist/core/gorp/GorpStreamResponse.js +51 -0
  21. package/dist/core/gorp/GorpStreamResponse.js.map +1 -0
  22. package/dist/core/gorp/changeTree.d.ts +16 -0
  23. package/dist/core/gorp/changeTree.d.ts.map +1 -0
  24. package/dist/core/gorp/changeTree.js +77 -0
  25. package/dist/core/gorp/changeTree.js.map +1 -0
  26. package/dist/core/gorp/createGorpStream.d.ts +16 -0
  27. package/dist/core/gorp/createGorpStream.d.ts.map +1 -0
  28. package/dist/core/{object/createObjectStream.js → gorp/createGorpStream.js} +13 -8
  29. package/dist/core/gorp/createGorpStream.js.map +1 -0
  30. package/dist/core/{object → gorp}/types.d.ts +6 -5
  31. package/dist/core/gorp/types.d.ts.map +1 -0
  32. package/dist/core/modules/assistant-stream.d.ts +5 -4
  33. package/dist/core/modules/assistant-stream.d.ts.map +1 -1
  34. package/dist/core/modules/assistant-stream.js +10 -6
  35. package/dist/core/modules/assistant-stream.js.map +1 -1
  36. package/dist/core/modules/tool-call.d.ts.map +1 -1
  37. package/dist/core/modules/tool-call.js +3 -3
  38. package/dist/core/modules/tool-call.js.map +1 -1
  39. package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts.map +1 -1
  40. package/dist/core/serialization/assistant-transport/AssistantTransport.js +91 -1
  41. package/dist/core/serialization/assistant-transport/AssistantTransport.js.map +1 -1
  42. package/dist/core/serialization/data-stream/DataStream.js +48 -48
  43. package/dist/core/serialization/data-stream/DataStream.js.map +1 -1
  44. package/dist/core/serialization/data-stream/chunk-types.d.ts +25 -24
  45. package/dist/core/serialization/data-stream/chunk-types.d.ts.map +1 -1
  46. package/dist/core/serialization/data-stream/chunk-types.js +22 -23
  47. package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
  48. package/dist/core/serialization/data-stream/serialization.d.ts.map +1 -1
  49. package/dist/core/serialization/data-stream/serialization.js +14 -2
  50. package/dist/core/serialization/data-stream/serialization.js.map +1 -1
  51. package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
  52. package/dist/core/serialization/ui-message-stream/UIMessageStream.js +12 -1
  53. package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
  54. package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
  55. package/dist/core/tool/ToolCallReader.js.map +1 -1
  56. package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
  57. package/dist/core/tool/ToolExecutionStream.js +9 -1
  58. package/dist/core/tool/ToolExecutionStream.js.map +1 -1
  59. package/dist/core/tool/ToolResponse.d.ts.map +1 -1
  60. package/dist/core/tool/tool-types.d.ts +6 -0
  61. package/dist/core/tool/tool-types.d.ts.map +1 -1
  62. package/dist/core/tool/toolResultStream.d.ts.map +1 -1
  63. package/dist/core/tool/toolResultStream.js +7 -2
  64. package/dist/core/tool/toolResultStream.js.map +1 -1
  65. package/dist/core/utils/stream/AssistantTransformStream.d.ts.map +1 -1
  66. package/dist/core/utils/stream/PipeableTransformStream.d.ts.map +1 -1
  67. package/dist/core/utils/stream/SSE.d.ts.map +1 -1
  68. package/dist/core/utils/stream/SSE.js +11 -2
  69. package/dist/core/utils/stream/SSE.js.map +1 -1
  70. package/dist/core/utils/stream/SSEEventDecoder.d.ts.map +1 -1
  71. package/dist/core/utils/stream/merge.d.ts +2 -0
  72. package/dist/core/utils/stream/merge.d.ts.map +1 -1
  73. package/dist/core/utils/stream/merge.js +27 -8
  74. package/dist/core/utils/stream/merge.js.map +1 -1
  75. package/dist/core/utils/stream/path-utils.d.ts.map +1 -1
  76. package/dist/index.d.ts +25 -4
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +14 -3
  79. package/dist/index.js.map +1 -0
  80. package/dist/resumable/ResumableStreamContext.d.ts.map +1 -1
  81. package/dist/resumable/ResumableStreamContext.js +12 -5
  82. package/dist/resumable/ResumableStreamContext.js.map +1 -1
  83. package/dist/resumable/errors.d.ts.map +1 -1
  84. package/dist/resumable/stores/redis-impl.d.ts.map +1 -1
  85. package/dist/utils/AsyncIterableStream.d.ts.map +1 -1
  86. package/dist/utils/AsyncIterableStream.js +18 -3
  87. package/dist/utils/AsyncIterableStream.js.map +1 -1
  88. package/dist/utils/promiseWithResolvers.d.ts.map +1 -1
  89. package/package.json +2 -2
  90. package/src/core/AssistantStreamChunk.ts +3 -3
  91. package/src/core/accumulators/AssistantMessageStream.ts +3 -1
  92. package/src/core/accumulators/assistant-message-accumulator.test.ts +374 -1
  93. package/src/core/accumulators/assistant-message-accumulator.ts +75 -26
  94. package/src/core/converters/toGenericMessages.test.ts +3 -39
  95. package/src/core/{object/ObjectStream.test.ts → gorp/GorpStream.test.ts} +86 -25
  96. package/src/core/gorp/GorpStreamAccumulator.test.ts +28 -0
  97. package/src/core/{object/ObjectStreamAccumulator.ts → gorp/GorpStreamAccumulator.ts} +13 -15
  98. package/src/core/gorp/GorpStreamDeltaTracker.test.ts +212 -0
  99. package/src/core/gorp/GorpStreamDeltaTracker.ts +55 -0
  100. package/src/core/{object/ObjectStreamResponse.test.ts → gorp/GorpStreamResponse.test.ts} +6 -6
  101. package/src/core/gorp/GorpStreamResponse.ts +88 -0
  102. package/src/core/gorp/changeTree.ts +96 -0
  103. package/src/core/{object/createObjectStream.ts → gorp/createGorpStream.ts} +23 -17
  104. package/src/core/{object → gorp}/types.ts +5 -3
  105. package/src/core/modules/assistant-stream.test.ts +154 -2
  106. package/src/core/modules/assistant-stream.ts +16 -6
  107. package/src/core/modules/tool-call.test.ts +67 -0
  108. package/src/core/modules/tool-call.ts +7 -4
  109. package/src/core/serialization/assistant-transport/AssistantTransport.test.ts +174 -1
  110. package/src/core/serialization/assistant-transport/AssistantTransport.ts +77 -1
  111. package/src/core/serialization/data-stream/DataStream.ts +1 -1
  112. package/src/core/serialization/data-stream/chunk-types.ts +26 -24
  113. package/src/core/serialization/data-stream/serialization.test.ts +133 -0
  114. package/src/core/serialization/data-stream/serialization.ts +20 -2
  115. package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +135 -0
  116. package/src/core/serialization/ui-message-stream/UIMessageStream.ts +21 -1
  117. package/src/core/tool/ToolCallReader.ts +5 -1
  118. package/src/core/tool/ToolExecutionStream.ts +10 -0
  119. package/src/core/tool/tool-types.ts +7 -0
  120. package/src/core/tool/toolResultStream.test.ts +50 -0
  121. package/src/core/tool/toolResultStream.ts +7 -2
  122. package/src/core/utils/stream/SSE.test.ts +97 -0
  123. package/src/core/utils/stream/SSE.ts +13 -2
  124. package/src/core/utils/stream/merge.ts +31 -9
  125. package/src/gorp.test.ts +32 -0
  126. package/src/index.ts +21 -6
  127. package/src/resumable/ResumableStreamContext.test.ts +116 -1
  128. package/src/resumable/ResumableStreamContext.ts +32 -5
  129. package/src/resumable/createResumableAssistantStreamResponse.test.ts +1 -25
  130. package/src/utils/AsyncIterableStream.test.ts +61 -0
  131. package/src/utils/AsyncIterableStream.ts +18 -3
  132. package/dist/core/object/ObjectStreamAccumulator.d.ts +0 -15
  133. package/dist/core/object/ObjectStreamAccumulator.d.ts.map +0 -1
  134. package/dist/core/object/ObjectStreamAccumulator.js.map +0 -1
  135. package/dist/core/object/ObjectStreamResponse.d.ts +0 -16
  136. package/dist/core/object/ObjectStreamResponse.d.ts.map +0 -1
  137. package/dist/core/object/ObjectStreamResponse.js +0 -57
  138. package/dist/core/object/ObjectStreamResponse.js.map +0 -1
  139. package/dist/core/object/createObjectStream.d.ts +0 -16
  140. package/dist/core/object/createObjectStream.d.ts.map +0 -1
  141. package/dist/core/object/createObjectStream.js.map +0 -1
  142. package/dist/core/object/types.d.ts.map +0 -1
  143. package/src/core/accumulators/TimingTracker.test.ts +0 -100
  144. package/src/core/object/ObjectStreamResponse.ts +0 -114
  145. /package/dist/core/{object → gorp}/types.js +0 -0
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
  import { AssistantMessageAccumulator } from "./assistant-message-accumulator";
3
3
  import type { AssistantStreamChunk } from "../AssistantStreamChunk";
4
4
  import type { AssistantMessage } from "../utils/types";
@@ -224,6 +224,88 @@ describe("AssistantMessageAccumulator timing", () => {
224
224
  expect(last.metadata.timing).toBeDefined();
225
225
  expect(last.metadata.timing!.firstTokenTime).toBeTypeOf("number");
226
226
  });
227
+
228
+ it("does not record firstTokenTime when a text-delta is dropped for an out-of-range path", async () => {
229
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
230
+ const messages = await collectStream([
231
+ { type: "part-start", path: [0], part: { type: "text" } },
232
+ { type: "text-delta", path: [5], textDelta: "x" },
233
+ ]);
234
+ const last = messages.at(-1)!;
235
+
236
+ expect(last.metadata.timing).toBeDefined();
237
+ expect(last.metadata.timing!.firstTokenTime).toBeUndefined();
238
+ warn.mockRestore();
239
+ });
240
+
241
+ it("does not record firstTokenTime when a text-delta is dropped for a nested path", async () => {
242
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
243
+ const messages = await collectStream([
244
+ { type: "part-start", path: [0], part: { type: "text" } },
245
+ { type: "text-delta", path: [0, 1], textDelta: "x" },
246
+ ]);
247
+ const last = messages.at(-1)!;
248
+
249
+ expect(last.metadata.timing).toBeDefined();
250
+ expect(last.metadata.timing!.firstTokenTime).toBeUndefined();
251
+ warn.mockRestore();
252
+ });
253
+
254
+ it("does not record firstTokenTime when a text-delta is dropped for a wrong part type", async () => {
255
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
256
+ const messages = await collectStream([
257
+ {
258
+ type: "part-start",
259
+ path: [0],
260
+ part: {
261
+ type: "source",
262
+ sourceType: "url",
263
+ id: "s1",
264
+ url: "https://example.com",
265
+ },
266
+ },
267
+ { type: "text-delta", path: [0], textDelta: "x" },
268
+ ]);
269
+ const last = messages.at(-1)!;
270
+
271
+ expect(last.metadata.timing).toBeDefined();
272
+ expect(last.metadata.timing!.firstTokenTime).toBeUndefined();
273
+ warn.mockRestore();
274
+ });
275
+
276
+ it("records firstTokenTime on the first applied text-delta, not a preceding dropped one", async () => {
277
+ vi.useFakeTimers();
278
+ vi.setSystemTime(1000);
279
+
280
+ const accumulator = new AssistantMessageAccumulator();
281
+ const writer = accumulator.writable.getWriter();
282
+ const messages: AssistantMessage[] = [];
283
+ const reading = (async () => {
284
+ const reader = accumulator.readable.getReader();
285
+ for (;;) {
286
+ const { done, value } = await reader.read();
287
+ if (done) break;
288
+ messages.push(value);
289
+ }
290
+ })();
291
+
292
+ await writer.write({
293
+ type: "part-start",
294
+ path: [0],
295
+ part: { type: "text" },
296
+ });
297
+ vi.setSystemTime(2000);
298
+ await writer.write({ type: "text-delta", path: [5], textDelta: "x" });
299
+ vi.setSystemTime(3000);
300
+ await writer.write({ type: "text-delta", path: [0], textDelta: "hi" });
301
+ await writer.close();
302
+ await reading;
303
+ vi.useRealTimers();
304
+
305
+ const last = messages.at(-1)!;
306
+ expect(last.metadata.timing).toBeDefined();
307
+ expect(last.metadata.timing!.firstTokenTime).toBe(2000);
308
+ });
227
309
  });
228
310
 
229
311
  describe("AssistantMessageAccumulator error chunks", () => {
@@ -240,6 +322,19 @@ describe("AssistantMessageAccumulator error chunks", () => {
240
322
  });
241
323
  });
242
324
 
325
+ it("defaults a missing error message", async () => {
326
+ const messages = await collectStream([
327
+ { type: "error", path: [] } as unknown as AssistantStreamChunk,
328
+ ]);
329
+ const last = messages.at(-1)!;
330
+
331
+ expect(last.status).toEqual({
332
+ type: "incomplete",
333
+ reason: "error",
334
+ error: { code: "unknown", message: "unknown error" },
335
+ });
336
+ });
337
+
243
338
  it("carries code and severity through unchanged", async () => {
244
339
  const messages = await collectStream([
245
340
  {
@@ -288,3 +383,281 @@ describe("AssistantMessageAccumulator error chunks", () => {
288
383
  });
289
384
  });
290
385
  });
386
+
387
+ describe("AssistantMessageAccumulator part path bounds", () => {
388
+ it("drops a text-delta whose path is out of range with a warning", async () => {
389
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
390
+ const messages = await collectStream([
391
+ { type: "part-start", path: [0], part: { type: "text" } },
392
+ { type: "text-delta", path: [5], textDelta: "x" },
393
+ { type: "text-delta", path: [0], textDelta: "hi" },
394
+ ]);
395
+ const last = messages.at(-1)!;
396
+
397
+ expect(last.parts).toHaveLength(1);
398
+ expect(last.parts[0]).toMatchObject({ type: "text", text: "hi" });
399
+ expect(warn).toHaveBeenCalledWith(
400
+ "Dropped text-delta chunk: no part at path [5]",
401
+ );
402
+ warn.mockRestore();
403
+ });
404
+
405
+ it("drops an out-of-range part-finish instead of fabricating a part", async () => {
406
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
407
+ const messages = await collectStream([
408
+ { type: "part-start", path: [0], part: { type: "text" } },
409
+ { type: "part-finish", path: [3] },
410
+ ]);
411
+ const last = messages.at(-1)!;
412
+
413
+ expect(last.parts).toHaveLength(1);
414
+ expect(last.parts[0]!.type).toBe("text");
415
+ warn.mockRestore();
416
+ });
417
+
418
+ it("drops part chunks when no parts exist", async () => {
419
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
420
+ const messages = await collectStream([
421
+ { type: "text-delta", path: [0], textDelta: "x" },
422
+ { type: "part-start", path: [0], part: { type: "text" } },
423
+ { type: "text-delta", path: [0], textDelta: "ok" },
424
+ ]);
425
+ const last = messages.at(-1)!;
426
+
427
+ expect(last.parts).toHaveLength(1);
428
+ expect(last.parts[0]).toMatchObject({ type: "text", text: "ok" });
429
+ warn.mockRestore();
430
+ });
431
+
432
+ it("drops chunks with nested paths", async () => {
433
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
434
+ const messages = await collectStream([
435
+ { type: "part-start", path: [0], part: { type: "text" } },
436
+ { type: "text-delta", path: [0, 1], textDelta: "x" },
437
+ { type: "text-delta", path: [0], textDelta: "ok" },
438
+ ]);
439
+ const last = messages.at(-1)!;
440
+
441
+ expect(last.parts).toHaveLength(1);
442
+ expect(last.parts[0]).toMatchObject({ type: "text", text: "ok" });
443
+ warn.mockRestore();
444
+ });
445
+
446
+ it("drops an out-of-range result without touching existing tool calls", async () => {
447
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
448
+ const messages = await collectStream([
449
+ {
450
+ type: "part-start",
451
+ path: [0],
452
+ part: { type: "tool-call", toolCallId: "t1", toolName: "f" },
453
+ },
454
+ { type: "text-delta", path: [0], textDelta: "{}" },
455
+ { type: "result", path: [7], result: { ok: true }, isError: false },
456
+ { type: "result", path: [0], result: { ok: true }, isError: false },
457
+ ]);
458
+ const last = messages.at(-1)!;
459
+
460
+ expect(last.parts).toHaveLength(1);
461
+ expect(last.parts[0]).toMatchObject({
462
+ type: "tool-call",
463
+ state: "result",
464
+ result: { ok: true },
465
+ });
466
+ warn.mockRestore();
467
+ });
468
+ });
469
+
470
+ describe("AssistantMessageAccumulator wrong part type chunks", () => {
471
+ const sourcePart = {
472
+ type: "source",
473
+ sourceType: "url",
474
+ id: "s1",
475
+ url: "https://example.com",
476
+ } as const;
477
+
478
+ it("drops a text-delta addressed to a source part", async () => {
479
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
480
+ const messages = await collectStream([
481
+ { type: "part-start", path: [0], part: sourcePart },
482
+ { type: "text-delta", path: [0], textDelta: "x" },
483
+ ]);
484
+ const last = messages.at(-1)!;
485
+
486
+ expect(last.parts[0]).toMatchObject({ type: "source", id: "s1" });
487
+ expect(warn).toHaveBeenCalledWith(
488
+ "Dropped text-delta chunk: part is neither text nor tool-call",
489
+ );
490
+ warn.mockRestore();
491
+ });
492
+
493
+ it("drops a result addressed to a text part", async () => {
494
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
495
+ const messages = await collectStream([
496
+ { type: "part-start", path: [0], part: { type: "text" } },
497
+ { type: "text-delta", path: [0], textDelta: "hi" },
498
+ { type: "result", path: [0], result: { ok: true }, isError: false },
499
+ ]);
500
+ const last = messages.at(-1)!;
501
+
502
+ expect(last.parts[0]).toMatchObject({ type: "text", text: "hi" });
503
+ expect(warn).toHaveBeenCalledWith(
504
+ "Dropped result chunk: part is not a tool-call",
505
+ );
506
+ warn.mockRestore();
507
+ });
508
+
509
+ it("drops an args-text-finish addressed to a text part", async () => {
510
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
511
+ const messages = await collectStream([
512
+ { type: "part-start", path: [0], part: { type: "text" } },
513
+ { type: "tool-call-args-text-finish", path: [0] },
514
+ ]);
515
+ const last = messages.at(-1)!;
516
+
517
+ expect(last.parts[0]).toMatchObject({ type: "text" });
518
+ expect(warn).toHaveBeenCalledWith(
519
+ "Dropped tool-call-args-text-finish chunk: part is not a tool-call",
520
+ );
521
+ warn.mockRestore();
522
+ });
523
+
524
+ it("inserts a placeholder for an unsupported part-start so later part indices stay aligned", async () => {
525
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
526
+ const messages = await collectStream([
527
+ {
528
+ type: "part-start",
529
+ path: [0],
530
+ part: { type: "bogus" },
531
+ } as unknown as AssistantStreamChunk,
532
+ { type: "part-start", path: [1], part: { type: "text" } },
533
+ { type: "text-delta", path: [1], textDelta: "ok" },
534
+ { type: "part-finish", path: [0] },
535
+ ]);
536
+ const last = messages.at(-1)!;
537
+
538
+ expect(last.parts).toHaveLength(2);
539
+ expect(last.parts[0]).toMatchObject({
540
+ type: "reasoning",
541
+ text: "",
542
+ status: { type: "complete", reason: "unknown" },
543
+ });
544
+ expect(last.parts[1]).toMatchObject({ type: "text", text: "ok" });
545
+ expect(warn).toHaveBeenCalledWith(
546
+ "Unsupported part-start type bogus: inserting an empty reasoning part to preserve part indices",
547
+ );
548
+ warn.mockRestore();
549
+ });
550
+ });
551
+
552
+ describe("AssistantMessageAccumulator warn dedup", () => {
553
+ it("warns once per drop class per instance", async () => {
554
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
555
+ await collectStream([
556
+ { type: "part-start", path: [0], part: { type: "text" } },
557
+ { type: "text-delta", path: [5], textDelta: "a" },
558
+ { type: "text-delta", path: [5], textDelta: "b" },
559
+ { type: "text-delta", path: [6], textDelta: "c" },
560
+ { type: "part-finish", path: [9] },
561
+ ]);
562
+
563
+ expect(warn).toHaveBeenCalledTimes(2);
564
+ expect(warn).toHaveBeenCalledWith(
565
+ "Dropped text-delta chunk: no part at path [5]",
566
+ );
567
+ expect(warn).toHaveBeenCalledWith(
568
+ "Dropped part-finish chunk: no part at path [9]",
569
+ );
570
+ warn.mockRestore();
571
+ });
572
+
573
+ it("resets dedup per accumulator instance", async () => {
574
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
575
+ const stream = [
576
+ { type: "part-start", path: [0], part: { type: "text" } },
577
+ { type: "text-delta", path: [5], textDelta: "x" },
578
+ ] satisfies AssistantStreamChunk[];
579
+ await collectStream(stream);
580
+ await collectStream(stream);
581
+
582
+ expect(warn).toHaveBeenCalledTimes(2);
583
+ warn.mockRestore();
584
+ });
585
+
586
+ it("dedupes repeated wrong-part-type drops", async () => {
587
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
588
+ await collectStream([
589
+ {
590
+ type: "part-start",
591
+ path: [0],
592
+ part: { type: "source", sourceType: "url", id: "s", url: "https://x" },
593
+ },
594
+ { type: "text-delta", path: [0], textDelta: "a" },
595
+ { type: "text-delta", path: [0], textDelta: "b" },
596
+ ]);
597
+
598
+ expect(warn).toHaveBeenCalledTimes(1);
599
+ warn.mockRestore();
600
+ });
601
+ });
602
+
603
+ describe("AssistantMessageAccumulator warn dedup key independence", () => {
604
+ it("keeps drop classes as independent keys within one stream", async () => {
605
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
606
+ await collectStream([
607
+ {
608
+ type: "part-start",
609
+ path: [0],
610
+ part: { type: "source", sourceType: "url", id: "s", url: "https://x" },
611
+ },
612
+ { type: "text-delta", path: [0], textDelta: "wrong-part" },
613
+ { type: "text-delta", path: [7], textDelta: "no-part" },
614
+ ]);
615
+
616
+ expect(warn).toHaveBeenCalledTimes(2);
617
+ warn.mockRestore();
618
+ });
619
+
620
+ it("keys unsupported part-starts per type", async () => {
621
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
622
+ const messages = await collectStream([
623
+ {
624
+ type: "part-start",
625
+ path: [0],
626
+ part: { type: "bogus" },
627
+ } as unknown as AssistantStreamChunk,
628
+ {
629
+ type: "part-start",
630
+ path: [1],
631
+ part: { type: "bogus" },
632
+ } as unknown as AssistantStreamChunk,
633
+ {
634
+ type: "part-start",
635
+ path: [2],
636
+ part: { type: "video" },
637
+ } as unknown as AssistantStreamChunk,
638
+ ]);
639
+ const last = messages.at(-1)!;
640
+
641
+ expect(last.parts).toHaveLength(3);
642
+ expect(warn).toHaveBeenCalledTimes(2);
643
+ warn.mockRestore();
644
+ });
645
+
646
+ it("caps the number of warned keys per instance", async () => {
647
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
648
+ await collectStream(
649
+ Array.from(
650
+ { length: 20 },
651
+ (_, i) =>
652
+ ({
653
+ type: "part-start",
654
+ path: [i],
655
+ part: { type: `bogus-${i}` },
656
+ }) as unknown as AssistantStreamChunk,
657
+ ),
658
+ );
659
+
660
+ expect(warn).toHaveBeenCalledTimes(16);
661
+ warn.mockRestore();
662
+ });
663
+ });
@@ -13,7 +13,7 @@ import type {
13
13
  FilePart,
14
14
  DataPart,
15
15
  } from "../utils/types";
16
- import { ObjectStreamAccumulator } from "../object/ObjectStreamAccumulator";
16
+ import { GorpStreamAccumulator } from "../gorp/GorpStreamAccumulator";
17
17
  import type { ReadonlyJSONValue } from "../../utils";
18
18
  import { TimingTracker } from "./TimingTracker";
19
19
 
@@ -37,20 +37,29 @@ export const createInitialMessage = ({
37
37
  },
38
38
  });
39
39
 
40
+ type WarnOnce = (key: string, message: string) => void;
41
+
42
+ const MAX_WARNED_KEYS = 16;
43
+
40
44
  const updatePartForPath = (
41
45
  message: AssistantMessage,
42
46
  chunk: AssistantStreamChunk,
47
+ warnOnce: WarnOnce,
43
48
  updater: (part: AssistantMessagePart) => AssistantMessagePart,
44
49
  ): AssistantMessage => {
45
- if (message.parts.length === 0) {
46
- throw new Error("No parts available to update.");
50
+ const part =
51
+ chunk.path.length === 1 ? message.parts[chunk.path[0]!] : undefined;
52
+ if (part === undefined) {
53
+ warnOnce(
54
+ `no-part:${chunk.type}`,
55
+ `Dropped ${chunk.type} chunk: no part at path [${chunk.path.join(", ")}]`,
56
+ );
57
+ return message;
47
58
  }
48
59
 
49
- if (chunk.path.length !== 1)
50
- throw new Error("Nested paths are not supported yet.");
51
-
52
60
  const partIndex = chunk.path[0]!;
53
- const updatedPart = updater(message.parts[partIndex]!);
61
+ const updatedPart = updater(part);
62
+ if (updatedPart === part) return message;
54
63
  return {
55
64
  ...message,
56
65
  parts: [
@@ -67,6 +76,7 @@ const updatePartForPath = (
67
76
  const handlePartStart = (
68
77
  message: AssistantMessage,
69
78
  chunk: AssistantStreamChunk & { readonly type: "part-start" },
79
+ warnOnce: WarnOnce,
70
80
  ): AssistantMessage => {
71
81
  const partInit = chunk.part;
72
82
  if (partInit.type === "text" || partInit.type === "reasoning") {
@@ -147,7 +157,24 @@ const handlePartStart = (
147
157
  },
148
158
  };
149
159
  } else {
150
- throw new Error(`Unsupported part type: ${partInit.type}`);
160
+ const unsupportedType = String((partInit as { type?: unknown }).type);
161
+ warnOnce(
162
+ `unsupported-part-start:${unsupportedType}`,
163
+ `Unsupported part-start type ${unsupportedType}: inserting an empty reasoning part to preserve part indices`,
164
+ );
165
+ // reasoning rather than a data sentinel: auiV0Encode rejects data parts, so a data placeholder would fail cloud persistence
166
+ const placeholderPart: ReasoningPart = {
167
+ type: "reasoning",
168
+ text: "",
169
+ status: { type: "running" },
170
+ };
171
+ return {
172
+ ...message,
173
+ parts: [...message.parts, placeholderPart],
174
+ get content() {
175
+ return this.parts;
176
+ },
177
+ };
151
178
  }
152
179
  };
153
180
 
@@ -156,14 +183,19 @@ const handleToolCallArgsTextFinish = (
156
183
  chunk: AssistantStreamChunk & {
157
184
  readonly type: "tool-call-args-text-finish";
158
185
  },
186
+ warnOnce: WarnOnce,
159
187
  ): AssistantMessage => {
160
- return updatePartForPath(message, chunk, (part) => {
188
+ return updatePartForPath(message, chunk, warnOnce, (part) => {
161
189
  if (part.type !== "tool-call") {
162
- throw new Error("Last is not a tool call");
190
+ warnOnce(
191
+ "wrong-part:tool-call-args-text-finish",
192
+ "Dropped tool-call-args-text-finish chunk: part is not a tool-call",
193
+ );
194
+ return part;
163
195
  }
164
196
 
165
197
  // TODO this should never be hit; this happens if args-text-finish is emitted after result
166
- if (part.state !== "partial-call") return part;
198
+ if (part.state !== "partial-call") return { ...part };
167
199
  // throw new Error("Last is not a partial call");
168
200
 
169
201
  return {
@@ -176,8 +208,9 @@ const handleToolCallArgsTextFinish = (
176
208
  const handlePartFinish = (
177
209
  message: AssistantMessage,
178
210
  chunk: AssistantStreamChunk & { readonly type: "part-finish" },
211
+ warnOnce: WarnOnce,
179
212
  ): AssistantMessage => {
180
- return updatePartForPath(message, chunk, (part) => ({
213
+ return updatePartForPath(message, chunk, warnOnce, (part) => ({
181
214
  ...part,
182
215
  status: { type: "complete", reason: "unknown" },
183
216
  }));
@@ -186,8 +219,9 @@ const handlePartFinish = (
186
219
  const handleTextDelta = (
187
220
  message: AssistantMessage,
188
221
  chunk: AssistantStreamChunk & { type: "text-delta" },
222
+ warnOnce: WarnOnce,
189
223
  ): AssistantMessage => {
190
- return updatePartForPath(message, chunk, (part) => {
224
+ return updatePartForPath(message, chunk, warnOnce, (part) => {
191
225
  if (part.type === "text" || part.type === "reasoning") {
192
226
  return { ...part, text: part.text + chunk.textDelta };
193
227
  } else if (part.type === "tool-call") {
@@ -198,9 +232,11 @@ const handleTextDelta = (
198
232
 
199
233
  return { ...part, argsText: newArgsText, args: newArgs };
200
234
  } else {
201
- throw new Error(
202
- "text-delta received but part is neither text nor tool-call",
235
+ warnOnce(
236
+ "wrong-part:text-delta",
237
+ "Dropped text-delta chunk: part is neither text nor tool-call",
203
238
  );
239
+ return part;
204
240
  }
205
241
  });
206
242
  };
@@ -208,8 +244,9 @@ const handleTextDelta = (
208
244
  const handleResult = (
209
245
  message: AssistantMessage,
210
246
  chunk: AssistantStreamChunk & { type: "result" },
247
+ warnOnce: WarnOnce,
211
248
  ): AssistantMessage => {
212
- return updatePartForPath(message, chunk, (part) => {
249
+ return updatePartForPath(message, chunk, warnOnce, (part) => {
213
250
  if (part.type === "tool-call") {
214
251
  return {
215
252
  ...part,
@@ -232,7 +269,11 @@ const handleResult = (
232
269
  status: { type: "complete", reason: "stop" },
233
270
  };
234
271
  } else {
235
- throw new Error("Result chunk received but part is not a tool-call");
272
+ warnOnce(
273
+ "wrong-part:result",
274
+ "Dropped result chunk: part is not a tool-call",
275
+ );
276
+ return part;
236
277
  }
237
278
  });
238
279
  };
@@ -377,7 +418,7 @@ const handleErrorChunk = (
377
418
  reason: "error",
378
419
  error: {
379
420
  code: chunk.code ?? "unknown",
380
- message: chunk.error,
421
+ message: chunk.error ?? "unknown error",
381
422
  ...(severity !== undefined && { severity }),
382
423
  },
383
424
  },
@@ -388,7 +429,7 @@ const handleUpdateState = (
388
429
  message: AssistantMessage,
389
430
  chunk: AssistantStreamChunk & { type: "update-state" },
390
431
  ): AssistantMessage => {
391
- const acc = new ObjectStreamAccumulator(message.metadata.unstable_state);
432
+ const acc = new GorpStreamAccumulator(message.metadata.unstable_state);
392
433
  acc.append(chunk.operations);
393
434
 
394
435
  return {
@@ -451,6 +492,12 @@ export class AssistantMessageAccumulator extends TransformStream<
451
492
  } = {}) {
452
493
  let message = initialMessage ?? createInitialMessage();
453
494
  const tracker = new TimingTracker();
495
+ const warnedKeys = new Set<string>();
496
+ const warnOnce: WarnOnce = (key, warning) => {
497
+ if (warnedKeys.has(key) || warnedKeys.size >= MAX_WARNED_KEYS) return;
498
+ warnedKeys.add(key);
499
+ console.warn(warning);
500
+ };
454
501
  let controller:
455
502
  | TransformStreamDefaultController<AssistantMessage>
456
503
  | undefined;
@@ -470,26 +517,28 @@ export class AssistantMessageAccumulator extends TransformStream<
470
517
  const type = chunk.type;
471
518
  switch (type) {
472
519
  case "part-start":
473
- message = handlePartStart(message, chunk);
520
+ message = handlePartStart(message, chunk, warnOnce);
474
521
  if (chunk.part.type === "tool-call") {
475
522
  tracker.recordToolCallStart(chunk.part.toolCallId);
476
523
  }
477
524
  break;
478
525
 
479
526
  case "tool-call-args-text-finish":
480
- message = handleToolCallArgsTextFinish(message, chunk);
527
+ message = handleToolCallArgsTextFinish(message, chunk, warnOnce);
481
528
  break;
482
529
 
483
530
  case "part-finish":
484
- message = handlePartFinish(message, chunk);
531
+ message = handlePartFinish(message, chunk, warnOnce);
485
532
  break;
486
533
 
487
- case "text-delta":
488
- message = handleTextDelta(message, chunk);
489
- tracker.recordFirstToken();
534
+ case "text-delta": {
535
+ const next = handleTextDelta(message, chunk, warnOnce);
536
+ if (next !== message) tracker.recordFirstToken();
537
+ message = next;
490
538
  break;
539
+ }
491
540
  case "result":
492
- message = handleResult(message, chunk);
541
+ message = handleResult(message, chunk, warnOnce);
493
542
  break;
494
543
  case "message-finish":
495
544
  message = handleMessageFinish(message, chunk);
@@ -563,66 +563,30 @@ describe("toGenericMessages", () => {
563
563
  expect(content[0]!.mediaType).toBe(expectedType);
564
564
  };
565
565
 
566
- it("handles jpg extension", () => {
566
+ it("maps known extensions to their media types", () => {
567
567
  testMediaType("https://example.com/photo.jpg", "image/jpeg");
568
- });
569
-
570
- it("handles jpeg extension", () => {
571
568
  testMediaType("https://example.com/photo.jpeg", "image/jpeg");
572
- });
573
-
574
- it("handles png extension", () => {
575
569
  testMediaType("https://example.com/photo.png", "image/png");
576
- });
577
-
578
- it("handles gif extension", () => {
579
570
  testMediaType("https://example.com/photo.gif", "image/gif");
580
- });
581
-
582
- it("handles webp extension", () => {
583
571
  testMediaType("https://example.com/photo.webp", "image/webp");
584
- });
585
-
586
- it("handles svg extension", () => {
587
572
  testMediaType("https://example.com/icon.svg", "image/svg+xml");
588
- });
589
-
590
- it("handles avif extension", () => {
591
573
  testMediaType("https://example.com/photo.avif", "image/avif");
592
- });
593
-
594
- it("handles bmp extension", () => {
595
574
  testMediaType("https://example.com/photo.bmp", "image/bmp");
596
- });
597
-
598
- it("handles ico extension", () => {
599
575
  testMediaType("https://example.com/favicon.ico", "image/x-icon");
600
- });
601
-
602
- it("handles tiff extension", () => {
603
576
  testMediaType("https://example.com/photo.tiff", "image/tiff");
604
- });
605
-
606
- it("handles tif extension", () => {
607
577
  testMediaType("https://example.com/photo.tif", "image/tiff");
608
578
  });
609
579
 
610
- it("defaults to image/png for unknown extension", () => {
580
+ it("defaults to image/png for unknown or missing extensions", () => {
611
581
  testMediaType("https://example.com/photo.unknown", "image/png");
612
- });
613
-
614
- it("defaults to image/png for no extension", () => {
615
582
  testMediaType("https://example.com/photo", "image/png");
616
583
  });
617
584
 
618
- it("handles query params in URL", () => {
585
+ it("ignores query params and casing in the extension", () => {
619
586
  testMediaType(
620
587
  "https://example.com/photo.jpg?size=large&quality=high",
621
588
  "image/jpeg",
622
589
  );
623
- });
624
-
625
- it("handles uppercase extensions", () => {
626
590
  testMediaType("https://example.com/photo.JPG", "image/jpeg");
627
591
  });
628
592
  });