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, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  AssistantTransportEncoder,
4
4
  AssistantTransportDecoder,
@@ -47,6 +47,18 @@ async function encodeAndDecode(
47
47
  return reconstructedStream.pipeThrough(new AssistantTransportDecoder());
48
48
  }
49
49
 
50
+ function sseStream(frames: string[]): ReadableStream<AssistantStreamChunk> {
51
+ const sseText =
52
+ frames.map((frame) => `data: ${frame}\n\n`).join("") + "data: [DONE]\n\n";
53
+
54
+ return new ReadableStream<Uint8Array>({
55
+ start(controller) {
56
+ controller.enqueue(new TextEncoder().encode(sseText));
57
+ controller.close();
58
+ },
59
+ }).pipeThrough(new AssistantTransportDecoder());
60
+ }
61
+
50
62
  describe("AssistantTransportEncoder", () => {
51
63
  it("should encode text-delta chunks to SSE format", async () => {
52
64
  const chunks: AssistantStreamChunk[] = [
@@ -223,6 +235,167 @@ describe("AssistantTransportDecoder", () => {
223
235
  );
224
236
  });
225
237
 
238
+ it("drops frames that are not objects", async () => {
239
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
240
+ const decodedChunks = await collectChunks(
241
+ sseStream([
242
+ "null",
243
+ "5",
244
+ '"text"',
245
+ "[1,2]",
246
+ '{"type":"text-delta","textDelta":"Hello","path":[]}',
247
+ ]),
248
+ );
249
+
250
+ expect(decodedChunks).toEqual([
251
+ { type: "text-delta", textDelta: "Hello", path: [] },
252
+ ]);
253
+ expect(warn).toHaveBeenCalledTimes(1);
254
+ expect(warn).toHaveBeenCalledWith(
255
+ expect.stringContaining("(not-an-object)"),
256
+ );
257
+ warn.mockRestore();
258
+ });
259
+
260
+ it("drops frames with a missing or unknown type", async () => {
261
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
262
+ const decodedChunks = await collectChunks(
263
+ sseStream([
264
+ '{"path":[]}',
265
+ '{"type":5,"path":[]}',
266
+ '{"type":"bogus","path":[]}',
267
+ '{"type":"toString","path":[]}',
268
+ '{"type":"part-finish","path":[]}',
269
+ ]),
270
+ );
271
+
272
+ expect(decodedChunks).toEqual([{ type: "part-finish", path: [] }]);
273
+ expect(warn).toHaveBeenCalledTimes(1);
274
+ warn.mockRestore();
275
+ });
276
+
277
+ it("drops frames with an invalid path", async () => {
278
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
279
+ const decodedChunks = await collectChunks(
280
+ sseStream([
281
+ '{"type":"text-delta","textDelta":"b","path":"0"}',
282
+ '{"type":"text-delta","textDelta":"c","path":[-1]}',
283
+ '{"type":"text-delta","textDelta":"d","path":[1.5]}',
284
+ '{"type":"text-delta","textDelta":"e","path":["0"]}',
285
+ '{"type":"text-delta","textDelta":"f","path":[0]}',
286
+ ]),
287
+ );
288
+
289
+ expect(decodedChunks).toEqual([
290
+ { type: "text-delta", textDelta: "f", path: [0] },
291
+ ]);
292
+ expect(warn).toHaveBeenCalledTimes(1);
293
+ warn.mockRestore();
294
+ });
295
+
296
+ it("normalizes a missing path to an empty path on message-level chunks", async () => {
297
+ const decodedChunks = await collectChunks(
298
+ sseStream([
299
+ '{"type":"update-state","operations":[{"type":"set","path":["messages"],"value":[]}]}',
300
+ '{"type":"error","error":"boom"}',
301
+ ]),
302
+ );
303
+
304
+ expect(decodedChunks).toEqual([
305
+ {
306
+ type: "update-state",
307
+ operations: [{ type: "set", path: ["messages"], value: [] }],
308
+ path: [],
309
+ },
310
+ { type: "error", error: "boom", path: [] },
311
+ ]);
312
+ });
313
+
314
+ it("drops part-addressed chunks with a missing path", async () => {
315
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
316
+ const decodedChunks = await collectChunks(
317
+ sseStream([
318
+ '{"type":"text-delta","textDelta":"a"}',
319
+ '{"type":"part-finish"}',
320
+ '{"type":"result","result":{"ok":true}}',
321
+ '{"type":"text-delta","textDelta":"ok","path":[0]}',
322
+ ]),
323
+ );
324
+
325
+ expect(decodedChunks).toEqual([
326
+ { type: "text-delta", textDelta: "ok", path: [0] },
327
+ ]);
328
+ expect(warn).toHaveBeenCalledTimes(3);
329
+ warn.mockRestore();
330
+ });
331
+
332
+ it("drops frames that are not valid JSON", async () => {
333
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
334
+ const decodedChunks = await collectChunks(
335
+ sseStream([
336
+ '{"type":"text-delta"',
337
+ "not json",
338
+ '{"type":"text-delta","textDelta":"ok","path":[0]}',
339
+ ]),
340
+ );
341
+
342
+ expect(decodedChunks).toEqual([
343
+ { type: "text-delta", textDelta: "ok", path: [0] },
344
+ ]);
345
+ expect(warn).toHaveBeenCalledTimes(1);
346
+ warn.mockRestore();
347
+ });
348
+
349
+ it("drops frames missing per-type required fields", async () => {
350
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
351
+ const decodedChunks = await collectChunks(
352
+ sseStream([
353
+ '{"type":"part-start","path":[]}',
354
+ '{"type":"part-start","part":[],"path":[]}',
355
+ '{"type":"text-delta","textDelta":5,"path":[0]}',
356
+ '{"type":"annotations","path":[]}',
357
+ '{"type":"data","path":[]}',
358
+ '{"type":"update-state","path":[]}',
359
+ '{"type":"message-finish","path":[]}',
360
+ '{"type":"step-finish","path":[]}',
361
+ '{"type":"error","path":[]}',
362
+ '{"type":"error","error":42,"path":[]}',
363
+ '{"type":"message-finish","finishReason":"stop","path":[]}',
364
+ '{"type":"error","error":"boom","path":[]}',
365
+ ]),
366
+ );
367
+
368
+ expect(decodedChunks).toEqual([
369
+ { type: "error", path: [] },
370
+ { type: "error", error: 42, path: [] },
371
+ { type: "message-finish", finishReason: "stop", path: [] },
372
+ { type: "error", error: "boom", path: [] },
373
+ ]);
374
+ expect(warn).toHaveBeenCalledTimes(7);
375
+ expect(warn).toHaveBeenCalledWith(
376
+ expect.stringContaining("(invalid-fields:part-start)"),
377
+ );
378
+ warn.mockRestore();
379
+ });
380
+
381
+ it("tolerates a result without isError or result but rejects a non-boolean isError", async () => {
382
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
383
+ const decodedChunks = await collectChunks(
384
+ sseStream([
385
+ '{"type":"result","result":null,"path":[0]}',
386
+ '{"type":"result","path":[0]}',
387
+ '{"type":"result","result":1,"isError":"no","path":[0]}',
388
+ ]),
389
+ );
390
+
391
+ expect(decodedChunks).toEqual([
392
+ { type: "result", result: null, path: [0] },
393
+ { type: "result", path: [0] },
394
+ ]);
395
+ expect(warn).toHaveBeenCalledTimes(1);
396
+ warn.mockRestore();
397
+ });
398
+
226
399
  it("round-trips error chunks with code and severity", async () => {
227
400
  const originalChunks: AssistantStreamChunk[] = [
228
401
  {
@@ -1,3 +1,4 @@
1
+ import sjson from "secure-json-parse";
1
2
  import type { AssistantStreamChunk } from "../../AssistantStreamChunk";
2
3
  import { PipeableTransformStream } from "../../utils/stream/PipeableTransformStream";
3
4
  import {
@@ -6,6 +7,70 @@ import {
6
7
  } from "../../utils/stream/SSEEventDecoderStream";
7
8
  import type { AssistantStreamEncoder } from "../../AssistantStream";
8
9
 
10
+ type ChunkFields = Record<string, unknown>;
11
+
12
+ type ChunkRule = {
13
+ kind: "message" | "part-addressed";
14
+ valid: (chunk: ChunkFields) => boolean;
15
+ };
16
+
17
+ const noFields = () => true;
18
+ const requiredObject = (key: string) => (c: ChunkFields) => {
19
+ const value = c[key];
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ };
22
+ const requiredString = (key: string) => (c: ChunkFields) =>
23
+ typeof c[key] === "string";
24
+ const requiredArray = (key: string) => (c: ChunkFields) =>
25
+ Array.isArray(c[key]);
26
+ const optionalBoolean = (key: string) => (c: ChunkFields) =>
27
+ c[key] === undefined || typeof c[key] === "boolean";
28
+
29
+ const KNOWN_CHUNK_TYPES: Record<AssistantStreamChunk["type"], ChunkRule> = {
30
+ "part-start": { kind: "message", valid: requiredObject("part") },
31
+ "part-finish": { kind: "part-addressed", valid: noFields },
32
+ "tool-call-args-text-finish": { kind: "part-addressed", valid: noFields },
33
+ "text-delta": { kind: "part-addressed", valid: requiredString("textDelta") },
34
+ annotations: { kind: "message", valid: requiredArray("annotations") },
35
+ data: { kind: "message", valid: requiredArray("data") },
36
+ "step-start": { kind: "message", valid: noFields },
37
+ "step-finish": { kind: "message", valid: requiredString("finishReason") },
38
+ "message-finish": { kind: "message", valid: requiredString("finishReason") },
39
+ result: { kind: "part-addressed", valid: optionalBoolean("isError") },
40
+ error: { kind: "message", valid: noFields },
41
+ "update-state": { kind: "message", valid: requiredArray("operations") },
42
+ };
43
+
44
+ const parseChunk = (data: string): AssistantStreamChunk | string => {
45
+ let value: unknown;
46
+ try {
47
+ value = sjson.parse(data);
48
+ } catch {
49
+ return "unparseable";
50
+ }
51
+ if (typeof value !== "object" || value === null || Array.isArray(value))
52
+ return "not-an-object";
53
+ const { type, path } = value as { type?: unknown; path?: unknown };
54
+ if (
55
+ typeof type !== "string" ||
56
+ !Object.prototype.hasOwnProperty.call(KNOWN_CHUNK_TYPES, type)
57
+ )
58
+ return "unknown-type";
59
+ const rule = KNOWN_CHUNK_TYPES[type as AssistantStreamChunk["type"]];
60
+ if (!rule.valid(value as Record<string, unknown>))
61
+ return `invalid-fields:${type}`;
62
+ if (path === undefined) {
63
+ if (rule.kind !== "message") return `missing-path:${type}`;
64
+ return { ...value, path: [] } as unknown as AssistantStreamChunk;
65
+ }
66
+ if (
67
+ !Array.isArray(path) ||
68
+ !path.every((entry) => Number.isInteger(entry) && entry >= 0)
69
+ )
70
+ return "invalid-path";
71
+ return value as AssistantStreamChunk;
72
+ };
73
+
9
74
  /**
10
75
  * AssistantTransportEncoder encodes AssistantStreamChunks into SSE format
11
76
  * and emits [DONE] when the stream completes.
@@ -49,6 +114,7 @@ export class AssistantTransportDecoder extends PipeableTransformStream<
49
114
  constructor() {
50
115
  super((readable) => {
51
116
  let receivedDone = false;
117
+ const warnedReasons = new Set<string>();
52
118
 
53
119
  return readable
54
120
  .pipeThrough(new TextDecoderStream())
@@ -64,7 +130,17 @@ export class AssistantTransportDecoder extends PipeableTransformStream<
64
130
  // Stop processing when we encounter [DONE]
65
131
  controller.terminate();
66
132
  } else {
67
- controller.enqueue(JSON.parse(event.data));
133
+ const chunk = parseChunk(event.data);
134
+ if (typeof chunk === "string") {
135
+ if (!warnedReasons.has(chunk)) {
136
+ warnedReasons.add(chunk);
137
+ console.warn(
138
+ `Dropped invalid assistant-transport chunk (${chunk}): ${event.data.slice(0, 200)}`,
139
+ );
140
+ }
141
+ } else {
142
+ controller.enqueue(chunk);
143
+ }
68
144
  }
69
145
  break;
70
146
  default:
@@ -208,7 +208,7 @@ export class DataStreamEncoder
208
208
  }
209
209
  }
210
210
 
211
- const TOOL_CALL_ARGS_CLOSING_CHUNKS = [
211
+ const TOOL_CALL_ARGS_CLOSING_CHUNKS: DataStreamStreamChunkType[] = [
212
212
  DataStreamStreamChunkType.StartToolCall,
213
213
  DataStreamStreamChunkType.ToolCall,
214
214
  DataStreamStreamChunkType.TextDelta,
@@ -2,7 +2,7 @@ import type {
2
2
  ReadonlyJSONObject,
3
3
  ReadonlyJSONValue,
4
4
  } from "../../../utils/json/json-value";
5
- import type { ObjectStreamOperation } from "../../object/types";
5
+ import type { GorpStreamOperation } from "../../gorp/types";
6
6
 
7
7
  export type DataStreamChunk = {
8
8
  [K in DataStreamStreamChunkType]: {
@@ -25,29 +25,31 @@ type LanguageModelV1Usage = {
25
25
  outputTokens: number;
26
26
  };
27
27
 
28
- export enum DataStreamStreamChunkType {
29
- TextDelta = "0",
30
- Data = "2",
31
- Error = "3",
32
- Annotation = "8",
33
- ToolCall = "9",
34
- ToolCallResult = "a",
35
- StartToolCall = "b",
36
- ToolCallArgsTextDelta = "c",
37
- FinishMessage = "d",
38
- FinishStep = "e",
39
- StartStep = "f",
40
- ReasoningDelta = "g",
41
- Source = "h",
42
- RedactedReasoning = "i",
43
- ReasoningSignature = "j",
44
- File = "k",
28
+ export const DataStreamStreamChunkType = {
29
+ TextDelta: "0",
30
+ Data: "2",
31
+ Error: "3",
32
+ Annotation: "8",
33
+ ToolCall: "9",
34
+ ToolCallResult: "a",
35
+ StartToolCall: "b",
36
+ ToolCallArgsTextDelta: "c",
37
+ FinishMessage: "d",
38
+ FinishStep: "e",
39
+ StartStep: "f",
40
+ ReasoningDelta: "g",
41
+ Source: "h",
42
+ RedactedReasoning: "i",
43
+ ReasoningSignature: "j",
44
+ File: "k",
45
45
 
46
- AuiUpdateStateOperations = "aui-state",
47
- AuiTextDelta = "aui-text-delta",
48
- AuiReasoningDelta = "aui-reasoning-delta",
49
- AuiDataPart = "aui-data",
50
- }
46
+ AuiUpdateStateOperations: "aui-state",
47
+ AuiTextDelta: "aui-text-delta",
48
+ AuiReasoningDelta: "aui-reasoning-delta",
49
+ AuiDataPart: "aui-data",
50
+ } as const;
51
+ export type DataStreamStreamChunkType =
52
+ (typeof DataStreamStreamChunkType)[keyof typeof DataStreamStreamChunkType];
51
53
  type DataStreamStreamChunkValue = {
52
54
  [DataStreamStreamChunkType.TextDelta]: string;
53
55
  [DataStreamStreamChunkType.Data]: ReadonlyJSONValue[];
@@ -100,7 +102,7 @@ type DataStreamStreamChunkValue = {
100
102
  [DataStreamStreamChunkType.File]: { data: string; mimeType: string };
101
103
 
102
104
  // aui-extensions
103
- [DataStreamStreamChunkType.AuiUpdateStateOperations]: ObjectStreamOperation[];
105
+ [DataStreamStreamChunkType.AuiUpdateStateOperations]: GorpStreamOperation[];
104
106
  [DataStreamStreamChunkType.AuiTextDelta]: {
105
107
  textDelta: string;
106
108
  parentId: string;
@@ -0,0 +1,133 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { LineDecoderStream } from "../../utils/stream/LineDecoderStream";
3
+ import {
4
+ DataStreamChunkDecoder,
5
+ DataStreamChunkEncoder,
6
+ } from "./serialization";
7
+ import { type DataStreamChunk, DataStreamStreamChunkType } from "./chunk-types";
8
+
9
+ async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> {
10
+ const reader = stream.getReader();
11
+ const chunks: T[] = [];
12
+ try {
13
+ while (true) {
14
+ const { done, value } = await reader.read();
15
+ if (done) break;
16
+ chunks.push(value);
17
+ }
18
+ } finally {
19
+ reader.releaseLock();
20
+ }
21
+ return chunks;
22
+ }
23
+
24
+ function createLineStream(lines: string[]): ReadableStream<string> {
25
+ return new ReadableStream<string>({
26
+ start(controller) {
27
+ for (const line of lines) controller.enqueue(line);
28
+ controller.close();
29
+ },
30
+ });
31
+ }
32
+
33
+ describe("DataStreamChunkDecoder", () => {
34
+ afterEach(() => {
35
+ vi.restoreAllMocks();
36
+ });
37
+
38
+ it("decodes text-delta chunks", async () => {
39
+ const chunks = await collectChunks(
40
+ createLineStream(['0:"hello"', '0:" world"']).pipeThrough(
41
+ new DataStreamChunkDecoder(),
42
+ ),
43
+ );
44
+ expect(chunks).toEqual([
45
+ { type: DataStreamStreamChunkType.TextDelta, value: "hello" },
46
+ { type: DataStreamStreamChunkType.TextDelta, value: " world" },
47
+ ]);
48
+ });
49
+
50
+ it("round-trips through DataStreamChunkEncoder", async () => {
51
+ const values: DataStreamChunk[] = [
52
+ { type: DataStreamStreamChunkType.TextDelta, value: "hello" },
53
+ { type: DataStreamStreamChunkType.Error, value: "boom" },
54
+ ];
55
+ const source = new ReadableStream<DataStreamChunk>({
56
+ start(controller) {
57
+ for (const v of values) controller.enqueue(v);
58
+ controller.close();
59
+ },
60
+ });
61
+ const decoded = await collectChunks(
62
+ source
63
+ .pipeThrough(new DataStreamChunkEncoder())
64
+ .pipeThrough(new LineDecoderStream())
65
+ .pipeThrough(new DataStreamChunkDecoder()),
66
+ );
67
+ expect(decoded).toEqual(values);
68
+ });
69
+
70
+ it("drops chunks carrying prototype-pollution keys", async () => {
71
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
72
+ const chunks = await collectChunks(
73
+ createLineStream([
74
+ '0:{"__proto__":{"polluted":true}}',
75
+ '0:{"constructor":{"prototype":{"polluted":true}}}',
76
+ '0:"ok"',
77
+ ]).pipeThrough(new DataStreamChunkDecoder()),
78
+ );
79
+ expect(warn).toHaveBeenCalledTimes(2);
80
+ expect(chunks).toEqual([
81
+ { type: DataStreamStreamChunkType.TextDelta, value: "ok" },
82
+ ]);
83
+ });
84
+
85
+ it("drops chunks that are not valid JSON", async () => {
86
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
87
+ const invalid = ["0:{not json", "0:not json"];
88
+ const chunks = await collectChunks(
89
+ createLineStream([...invalid, '0:"ok"']).pipeThrough(
90
+ new DataStreamChunkDecoder(),
91
+ ),
92
+ );
93
+ expect(warn.mock.calls.map((c) => c[0])).toEqual(
94
+ invalid.map((l) => `Dropped invalid data-stream chunk: ${l}`),
95
+ );
96
+ expect(chunks).toEqual([
97
+ { type: DataStreamStreamChunkType.TextDelta, value: "ok" },
98
+ ]);
99
+ });
100
+
101
+ it("skips blank and whitespace-only lines silently", async () => {
102
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
103
+ const chunks = await collectChunks(
104
+ createLineStream([
105
+ "",
106
+ '0:"hello"',
107
+ " ",
108
+ "\t",
109
+ '0:" world"',
110
+ ]).pipeThrough(new DataStreamChunkDecoder()),
111
+ );
112
+ expect(warn).not.toHaveBeenCalled();
113
+ expect(chunks).toEqual([
114
+ { type: DataStreamStreamChunkType.TextDelta, value: "hello" },
115
+ { type: DataStreamStreamChunkType.TextDelta, value: " world" },
116
+ ]);
117
+ });
118
+
119
+ it("drops a chunk without a colon separator with a warning", async () => {
120
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
121
+ const chunks = await collectChunks(
122
+ createLineStream(["garbage", '0:"ok"']).pipeThrough(
123
+ new DataStreamChunkDecoder(),
124
+ ),
125
+ );
126
+ expect(warn).toHaveBeenCalledWith(
127
+ "Dropped invalid data-stream chunk: garbage",
128
+ );
129
+ expect(chunks).toEqual([
130
+ { type: DataStreamStreamChunkType.TextDelta, value: "ok" },
131
+ ]);
132
+ });
133
+ });
@@ -1,3 +1,4 @@
1
+ import sjson from "secure-json-parse";
1
2
  import type { DataStreamChunk, DataStreamStreamChunkType } from "./chunk-types";
2
3
 
3
4
  export class DataStreamChunkEncoder extends TransformStream<
@@ -21,10 +22,27 @@ export class DataStreamChunkDecoder extends TransformStream<
21
22
  super({
22
23
  transform: (chunk, controller) => {
23
24
  const index = chunk.indexOf(":");
24
- if (index === -1) throw new Error("Invalid stream part");
25
+ if (index === -1) {
26
+ // Blank lines are benign data-stream framing (keepalive newlines,
27
+ // replay-buffer separators emitted on resume/reconnect).
28
+ if (chunk.trim().length === 0) return;
29
+ console.warn(
30
+ `Dropped invalid data-stream chunk: ${chunk.slice(0, 200)}`,
31
+ );
32
+ return;
33
+ }
34
+ let value;
35
+ try {
36
+ value = sjson.parse(chunk.slice(index + 1));
37
+ } catch {
38
+ console.warn(
39
+ `Dropped invalid data-stream chunk: ${chunk.slice(0, 200)}`,
40
+ );
41
+ return;
42
+ }
25
43
  controller.enqueue({
26
44
  type: chunk.slice(0, index) as DataStreamStreamChunkType,
27
- value: JSON.parse(chunk.slice(index + 1)),
45
+ value,
28
46
  });
29
47
  },
30
48
  });
@@ -575,4 +575,139 @@ describe("UIMessageStreamDecoder", () => {
575
575
  // Should not throw, should complete successfully
576
576
  expect(chunks.some((c) => c.type === "message-finish")).toBe(true);
577
577
  });
578
+
579
+ it("drops frames that are not objects with a string type", async () => {
580
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
581
+ const invalidFrames = [
582
+ "null",
583
+ "5",
584
+ '"text"',
585
+ "[1,2]",
586
+ '{"foo":1}',
587
+ '{"type":5}',
588
+ ];
589
+ const events = [
590
+ ...invalidFrames,
591
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
592
+ JSON.stringify({ type: "text-delta", id: "t", delta: "ok" }),
593
+ "[DONE]",
594
+ ];
595
+
596
+ const chunks = await collectChunks(
597
+ createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
598
+ );
599
+
600
+ expect(warn.mock.calls.map((c) => c[0])).toEqual(
601
+ invalidFrames.map((f) => `Dropped invalid UIMessageStream chunk: ${f}`),
602
+ );
603
+ expect(chunks.some((c) => c.type === "text-delta")).toBe(true);
604
+ warn.mockRestore();
605
+ });
606
+
607
+ it("drops frames carrying prototype-pollution keys", async () => {
608
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
609
+ const events = [
610
+ '{"type":"text-delta","delta":"x","__proto__":{"polluted":true}}',
611
+ '{"type":"text-delta","delta":"y","constructor":{"prototype":{"polluted":true}}}',
612
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
613
+ JSON.stringify({ type: "text-delta", id: "t", delta: "ok" }),
614
+ "[DONE]",
615
+ ];
616
+
617
+ const chunks = await collectChunks(
618
+ createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
619
+ );
620
+
621
+ expect(warn).toHaveBeenCalledTimes(2);
622
+ const deltas = chunks.filter(
623
+ (c): c is AssistantStreamChunk & { type: "text-delta" } =>
624
+ c.type === "text-delta",
625
+ );
626
+ expect(deltas).toHaveLength(1);
627
+ expect(deltas[0]!.textDelta).toBe("ok");
628
+ warn.mockRestore();
629
+ });
630
+
631
+ it("drops frames that are not valid JSON", async () => {
632
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
633
+ const invalidFrames = ['{"type":"te', "not json"];
634
+ const events = [
635
+ ...invalidFrames,
636
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
637
+ JSON.stringify({ type: "text-delta", id: "t", delta: "ok" }),
638
+ "[DONE]",
639
+ ];
640
+
641
+ const chunks = await collectChunks(
642
+ createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
643
+ );
644
+
645
+ expect(warn.mock.calls.map((c) => c[0])).toEqual(
646
+ invalidFrames.map((f) => `Dropped invalid UIMessageStream chunk: ${f}`),
647
+ );
648
+ expect(chunks.some((c) => c.type === "text-delta")).toBe(true);
649
+ warn.mockRestore();
650
+ });
651
+
652
+ it("ignores a tool-call-delta arriving after tool-result", async () => {
653
+ const events = [
654
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
655
+ JSON.stringify({
656
+ type: "tool-call-start",
657
+ toolCallId: "call_abc",
658
+ toolName: "weather",
659
+ }),
660
+ JSON.stringify({ type: "tool-call-delta", argsText: '{"city":"NYC"}' }),
661
+ JSON.stringify({
662
+ type: "tool-result",
663
+ toolCallId: "call_abc",
664
+ result: { temp: 72 },
665
+ }),
666
+ JSON.stringify({ type: "tool-call-delta", argsText: '{"late":true}' }),
667
+ "[DONE]",
668
+ ];
669
+
670
+ const chunks = await collectChunks(
671
+ createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
672
+ );
673
+
674
+ const argDeltas = chunks.filter((c) => c.type === "text-delta");
675
+ expect(argDeltas).toHaveLength(1);
676
+ expect(chunks.some((c) => c.type === "result")).toBe(true);
677
+ });
678
+
679
+ it("keeps the active tool call writable when another call receives its result", async () => {
680
+ const events = [
681
+ JSON.stringify({
682
+ type: "tool-call-start",
683
+ toolCallId: "call_a",
684
+ toolName: "first",
685
+ }),
686
+ JSON.stringify({
687
+ type: "tool-call-start",
688
+ toolCallId: "call_b",
689
+ toolName: "second",
690
+ }),
691
+ JSON.stringify({
692
+ type: "tool-result",
693
+ toolCallId: "call_a",
694
+ result: { ok: true },
695
+ }),
696
+ JSON.stringify({ type: "tool-call-delta", argsText: '{"x":1}' }),
697
+ "[DONE]",
698
+ ];
699
+
700
+ const chunks = await collectChunks(
701
+ createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
702
+ );
703
+
704
+ const argDeltas = chunks.filter(
705
+ (c): c is AssistantStreamChunk & { type: "text-delta" } =>
706
+ c.type === "text-delta",
707
+ );
708
+ expect(argDeltas).toHaveLength(2);
709
+ const byPath = new Map(argDeltas.map((c) => [c.path[0], c.textDelta]));
710
+ expect(byPath.get(0)).toBe("{}");
711
+ expect(byPath.get(1)).toBe('{"x":1}');
712
+ });
578
713
  });