assistant-stream 0.3.25 → 0.3.27

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 (216) hide show
  1. package/README.md +4 -1
  2. package/dist/core/AssistantStream.d.ts +0 -1
  3. package/dist/core/AssistantStream.d.ts.map +1 -1
  4. package/dist/core/AssistantStreamChunk.d.ts +26 -14
  5. package/dist/core/AssistantStreamChunk.d.ts.map +1 -1
  6. package/dist/core/accumulators/AssistantMessageStream.d.ts +0 -1
  7. package/dist/core/accumulators/AssistantMessageStream.d.ts.map +1 -1
  8. package/dist/core/accumulators/TimingTracker.d.ts +0 -1
  9. package/dist/core/accumulators/TimingTracker.d.ts.map +1 -1
  10. package/dist/core/accumulators/assistant-message-accumulator.d.ts +3 -9
  11. package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
  12. package/dist/core/accumulators/assistant-message-accumulator.js +70 -28
  13. package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
  14. package/dist/core/converters/toGenericMessages.d.ts.map +1 -1
  15. package/dist/core/converters/toGenericMessages.js +2 -1
  16. package/dist/core/converters/toGenericMessages.js.map +1 -1
  17. package/dist/core/gorp/GorpStreamAccumulator.d.ts +15 -0
  18. package/dist/core/gorp/GorpStreamAccumulator.d.ts.map +1 -0
  19. package/dist/core/{object/ObjectStreamAccumulator.js → gorp/GorpStreamAccumulator.js} +11 -9
  20. package/dist/core/gorp/GorpStreamAccumulator.js.map +1 -0
  21. package/dist/core/gorp/GorpStreamDeltaTracker.d.ts +17 -0
  22. package/dist/core/gorp/GorpStreamDeltaTracker.d.ts.map +1 -0
  23. package/dist/core/gorp/GorpStreamDeltaTracker.js +38 -0
  24. package/dist/core/gorp/GorpStreamDeltaTracker.js.map +1 -0
  25. package/dist/core/gorp/GorpStreamResponse.d.ts +16 -0
  26. package/dist/core/gorp/GorpStreamResponse.d.ts.map +1 -0
  27. package/dist/core/gorp/GorpStreamResponse.js +51 -0
  28. package/dist/core/gorp/GorpStreamResponse.js.map +1 -0
  29. package/dist/core/gorp/changeTree.d.ts +16 -0
  30. package/dist/core/gorp/changeTree.d.ts.map +1 -0
  31. package/dist/core/gorp/changeTree.js +77 -0
  32. package/dist/core/gorp/changeTree.js.map +1 -0
  33. package/dist/core/gorp/createGorpStream.d.ts +16 -0
  34. package/dist/core/gorp/createGorpStream.d.ts.map +1 -0
  35. package/dist/core/{object/createObjectStream.js → gorp/createGorpStream.js} +13 -8
  36. package/dist/core/gorp/createGorpStream.js.map +1 -0
  37. package/dist/core/gorp/types.d.ts +20 -0
  38. package/dist/core/gorp/types.d.ts.map +1 -0
  39. package/dist/core/modules/assistant-stream.d.ts +18 -11
  40. package/dist/core/modules/assistant-stream.d.ts.map +1 -1
  41. package/dist/core/modules/assistant-stream.js +10 -6
  42. package/dist/core/modules/assistant-stream.js.map +1 -1
  43. package/dist/core/modules/text.d.ts +0 -1
  44. package/dist/core/modules/text.d.ts.map +1 -1
  45. package/dist/core/modules/tool-call.d.ts +0 -1
  46. package/dist/core/modules/tool-call.d.ts.map +1 -1
  47. package/dist/core/modules/tool-call.js +2 -2
  48. package/dist/core/modules/tool-call.js.map +1 -1
  49. package/dist/core/serialization/PlainText.d.ts +0 -1
  50. package/dist/core/serialization/PlainText.d.ts.map +1 -1
  51. package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts +0 -1
  52. package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts.map +1 -1
  53. package/dist/core/serialization/assistant-transport/AssistantTransport.js +93 -53
  54. package/dist/core/serialization/assistant-transport/AssistantTransport.js.map +1 -1
  55. package/dist/core/serialization/data-stream/DataStream.d.ts +0 -1
  56. package/dist/core/serialization/data-stream/DataStream.d.ts.map +1 -1
  57. package/dist/core/serialization/data-stream/chunk-types.d.ts +3 -4
  58. package/dist/core/serialization/data-stream/chunk-types.d.ts.map +1 -1
  59. package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
  60. package/dist/core/serialization/data-stream/serialization.d.ts +0 -1
  61. package/dist/core/serialization/data-stream/serialization.d.ts.map +1 -1
  62. package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts +0 -1
  63. package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
  64. package/dist/core/serialization/ui-message-stream/UIMessageStream.js +76 -53
  65. package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
  66. package/dist/core/serialization/ui-message-stream/chunk-types.d.ts +0 -1
  67. package/dist/core/serialization/ui-message-stream/chunk-types.d.ts.map +1 -1
  68. package/dist/core/tool/ToolCallReader.d.ts +1 -0
  69. package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
  70. package/dist/core/tool/ToolCallReader.js.map +1 -1
  71. package/dist/core/tool/ToolExecutionStream.d.ts +0 -1
  72. package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
  73. package/dist/core/tool/ToolExecutionStream.js +9 -1
  74. package/dist/core/tool/ToolExecutionStream.js.map +1 -1
  75. package/dist/core/tool/ToolResponse.d.ts +6 -4
  76. package/dist/core/tool/ToolResponse.d.ts.map +1 -1
  77. package/dist/core/tool/schema-utils.d.ts +0 -1
  78. package/dist/core/tool/schema-utils.d.ts.map +1 -1
  79. package/dist/core/tool/tool-types.d.ts +71 -33
  80. package/dist/core/tool/tool-types.d.ts.map +1 -1
  81. package/dist/core/tool/toolResultStream.d.ts +3 -2
  82. package/dist/core/tool/toolResultStream.d.ts.map +1 -1
  83. package/dist/core/tool/toolResultStream.js +7 -2
  84. package/dist/core/tool/toolResultStream.js.map +1 -1
  85. package/dist/core/tool/type-path-utils.d.ts +2 -2
  86. package/dist/core/tool/type-path-utils.d.ts.map +1 -1
  87. package/dist/core/utils/Counter.d.ts.map +1 -1
  88. package/dist/core/utils/generateId.d.ts.map +1 -1
  89. package/dist/core/utils/stream/AssistantMetaTransformStream.d.ts +0 -1
  90. package/dist/core/utils/stream/AssistantMetaTransformStream.d.ts.map +1 -1
  91. package/dist/core/utils/stream/AssistantTransformStream.d.ts +0 -1
  92. package/dist/core/utils/stream/AssistantTransformStream.d.ts.map +1 -1
  93. package/dist/core/utils/stream/LineDecoderStream.d.ts +1 -0
  94. package/dist/core/utils/stream/LineDecoderStream.d.ts.map +1 -1
  95. package/dist/core/utils/stream/LineDecoderStream.js +19 -6
  96. package/dist/core/utils/stream/LineDecoderStream.js.map +1 -1
  97. package/dist/core/utils/stream/PipeableTransformStream.d.ts.map +1 -1
  98. package/dist/core/utils/stream/SSE.d.ts +0 -1
  99. package/dist/core/utils/stream/SSE.d.ts.map +1 -1
  100. package/dist/core/utils/stream/SSE.js +2 -52
  101. package/dist/core/utils/stream/SSE.js.map +1 -1
  102. package/dist/core/utils/stream/SSEEventDecoder.d.ts +27 -0
  103. package/dist/core/utils/stream/SSEEventDecoder.d.ts.map +1 -0
  104. package/dist/core/utils/stream/SSEEventDecoder.js +84 -0
  105. package/dist/core/utils/stream/SSEEventDecoder.js.map +1 -0
  106. package/dist/core/utils/stream/SSEEventDecoderStream.d.ts +13 -0
  107. package/dist/core/utils/stream/SSEEventDecoderStream.d.ts.map +1 -0
  108. package/dist/core/utils/stream/SSEEventDecoderStream.js +24 -0
  109. package/dist/core/utils/stream/SSEEventDecoderStream.js.map +1 -0
  110. package/dist/core/utils/stream/UnderlyingReadable.d.ts.map +1 -1
  111. package/dist/core/utils/stream/merge.d.ts +2 -1
  112. package/dist/core/utils/stream/merge.d.ts.map +1 -1
  113. package/dist/core/utils/stream/merge.js +27 -8
  114. package/dist/core/utils/stream/merge.js.map +1 -1
  115. package/dist/core/utils/stream/path-utils.d.ts +0 -1
  116. package/dist/core/utils/stream/path-utils.d.ts.map +1 -1
  117. package/dist/core/utils/types.d.ts +16 -8
  118. package/dist/core/utils/types.d.ts.map +1 -1
  119. package/dist/core/utils/withPromiseOrValue.d.ts.map +1 -1
  120. package/dist/index.d.ts +25 -4
  121. package/dist/index.d.ts.map +1 -0
  122. package/dist/index.js +14 -3
  123. package/dist/index.js.map +1 -0
  124. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +3 -3
  125. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -1
  126. package/dist/resumable/ResumableStreamContext.d.ts +0 -1
  127. package/dist/resumable/ResumableStreamContext.d.ts.map +1 -1
  128. package/dist/resumable/ResumableStreamContext.js +12 -5
  129. package/dist/resumable/ResumableStreamContext.js.map +1 -1
  130. package/dist/resumable/constants.d.ts.map +1 -1
  131. package/dist/resumable/createResumableAssistantStreamResponse.d.ts +6 -4
  132. package/dist/resumable/createResumableAssistantStreamResponse.d.ts.map +1 -1
  133. package/dist/resumable/errors.d.ts.map +1 -1
  134. package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts +0 -1
  135. package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts.map +1 -1
  136. package/dist/resumable/stores/InMemoryResumableStreamStore.js +2 -1
  137. package/dist/resumable/stores/InMemoryResumableStreamStore.js.map +1 -1
  138. package/dist/resumable/stores/ioredis.d.ts +0 -1
  139. package/dist/resumable/stores/ioredis.d.ts.map +1 -1
  140. package/dist/resumable/stores/redis-impl.d.ts +2 -2
  141. package/dist/resumable/stores/redis-impl.d.ts.map +1 -1
  142. package/dist/resumable/stores/redis.d.ts +0 -1
  143. package/dist/resumable/stores/redis.d.ts.map +1 -1
  144. package/dist/resumable/types.d.ts.map +1 -1
  145. package/dist/utils/AsyncIterableStream.d.ts.map +1 -1
  146. package/dist/utils/AsyncIterableStream.js +18 -3
  147. package/dist/utils/AsyncIterableStream.js.map +1 -1
  148. package/dist/utils/json/fix-json.d.ts.map +1 -1
  149. package/dist/utils/json/json-value.d.ts.map +1 -1
  150. package/dist/utils/json/parse-partial-json-object.d.ts +0 -1
  151. package/dist/utils/json/parse-partial-json-object.d.ts.map +1 -1
  152. package/dist/utils/promiseWithResolvers.d.ts.map +1 -1
  153. package/dist/utils.d.ts +2 -1
  154. package/dist/utils.js +2 -1
  155. package/package.json +5 -5
  156. package/src/core/AssistantStreamChunk.ts +5 -3
  157. package/src/core/accumulators/assistant-message-accumulator.test.ts +437 -1
  158. package/src/core/accumulators/assistant-message-accumulator.ts +89 -26
  159. package/src/core/{object/ObjectStream.test.ts → gorp/GorpStream.test.ts} +104 -23
  160. package/src/core/gorp/GorpStreamAccumulator.test.ts +28 -0
  161. package/src/core/{object/ObjectStreamAccumulator.ts → gorp/GorpStreamAccumulator.ts} +13 -15
  162. package/src/core/gorp/GorpStreamDeltaTracker.test.ts +182 -0
  163. package/src/core/gorp/GorpStreamDeltaTracker.ts +55 -0
  164. package/src/core/gorp/GorpStreamResponse.test.ts +32 -0
  165. package/src/core/gorp/GorpStreamResponse.ts +88 -0
  166. package/src/core/gorp/changeTree.test.ts +168 -0
  167. package/src/core/gorp/changeTree.ts +96 -0
  168. package/src/core/{object/createObjectStream.ts → gorp/createGorpStream.ts} +23 -17
  169. package/src/core/{object → gorp}/types.ts +5 -3
  170. package/src/core/modules/assistant-stream.test.ts +154 -2
  171. package/src/core/modules/assistant-stream.ts +16 -6
  172. package/src/core/modules/tool-call.test.ts +67 -0
  173. package/src/core/modules/tool-call.ts +3 -3
  174. package/src/core/serialization/assistant-transport/AssistantTransport.test.ts +215 -1
  175. package/src/core/serialization/assistant-transport/AssistantTransport.ts +83 -71
  176. package/src/core/serialization/data-stream/chunk-types.ts +2 -2
  177. package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +323 -4
  178. package/src/core/serialization/ui-message-stream/UIMessageStream.ts +81 -71
  179. package/src/core/tool/ToolExecutionStream.ts +10 -0
  180. package/src/core/tool/toolResultStream.test.ts +50 -0
  181. package/src/core/tool/toolResultStream.ts +7 -2
  182. package/src/core/utils/stream/LineDecoderStream.test.ts +26 -2
  183. package/src/core/utils/stream/LineDecoderStream.ts +21 -11
  184. package/src/core/utils/stream/SSE.ts +6 -70
  185. package/src/core/utils/stream/SSEEventDecoder.test.ts +146 -0
  186. package/src/core/utils/stream/SSEEventDecoder.ts +109 -0
  187. package/src/core/utils/stream/SSEEventDecoderStream.test.ts +42 -0
  188. package/src/core/utils/stream/SSEEventDecoderStream.ts +34 -0
  189. package/src/core/utils/stream/merge.ts +31 -9
  190. package/src/gorp.test.ts +42 -0
  191. package/src/index.ts +21 -6
  192. package/src/resumable/ResumableStreamContext.test.ts +116 -1
  193. package/src/resumable/ResumableStreamContext.ts +32 -5
  194. package/src/resumable/createResumableAssistantStreamResponse.test.ts +1 -25
  195. package/src/utils/AsyncIterableStream.test.ts +61 -0
  196. package/src/utils/AsyncIterableStream.ts +18 -3
  197. package/src/utils.ts +4 -0
  198. package/dist/core/object/ObjectStreamAccumulator.d.ts +0 -15
  199. package/dist/core/object/ObjectStreamAccumulator.d.ts.map +0 -1
  200. package/dist/core/object/ObjectStreamAccumulator.js.map +0 -1
  201. package/dist/core/object/ObjectStreamResponse.d.ts +0 -17
  202. package/dist/core/object/ObjectStreamResponse.d.ts.map +0 -1
  203. package/dist/core/object/ObjectStreamResponse.js +0 -57
  204. package/dist/core/object/ObjectStreamResponse.js.map +0 -1
  205. package/dist/core/object/createObjectStream.d.ts +0 -19
  206. package/dist/core/object/createObjectStream.d.ts.map +0 -1
  207. package/dist/core/object/createObjectStream.js.map +0 -1
  208. package/dist/core/object/types.d.ts +0 -18
  209. package/dist/core/object/types.d.ts.map +0 -1
  210. package/dist/utils/json/is-json.d.ts +0 -9
  211. package/dist/utils/json/is-json.d.ts.map +0 -1
  212. package/dist/utils/json/is-json.js +0 -17
  213. package/dist/utils/json/is-json.js.map +0 -1
  214. package/src/core/object/ObjectStreamResponse.ts +0 -109
  215. package/src/utils/json/is-json.ts +0 -43
  216. /package/dist/core/{object → gorp}/types.js +0 -0
@@ -35,6 +35,34 @@ function createUIMessageStream(events: string[]): ReadableStream<Uint8Array> {
35
35
 
36
36
  describe("UIMessageStreamDecoder", () => {
37
37
  it("should decode text deltas", async () => {
38
+ const events = [
39
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
40
+ JSON.stringify({ type: "text-start", id: "text_1" }),
41
+ JSON.stringify({ type: "text-delta", id: "text_1", delta: "Hello" }),
42
+ JSON.stringify({ type: "text-delta", id: "text_1", delta: " world" }),
43
+ JSON.stringify({ type: "text-end" }),
44
+ JSON.stringify({
45
+ type: "finish",
46
+ finishReason: "stop",
47
+ usage: { inputTokens: 10, outputTokens: 5 },
48
+ }),
49
+ "[DONE]",
50
+ ];
51
+
52
+ const stream = createUIMessageStream(events);
53
+ const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
54
+ const chunks = await collectChunks(decodedStream);
55
+
56
+ const textDeltas = chunks.filter(
57
+ (c): c is AssistantStreamChunk & { type: "text-delta" } =>
58
+ c.type === "text-delta",
59
+ );
60
+ expect(textDeltas).toHaveLength(2);
61
+ expect(textDeltas[0]?.textDelta).toBe("Hello");
62
+ expect(textDeltas[1]?.textDelta).toBe(" world");
63
+ });
64
+
65
+ it("should decode legacy textDelta text deltas", async () => {
38
66
  const events = [
39
67
  JSON.stringify({ type: "start", messageId: "msg_123" }),
40
68
  JSON.stringify({ type: "text-start", id: "text_1" }),
@@ -138,7 +166,50 @@ describe("UIMessageStreamDecoder", () => {
138
166
  expect(result?.result).toEqual({ temp: 72 });
139
167
  });
140
168
 
141
- it("should decode source parts", async () => {
169
+ it("should decode source-url parts", async () => {
170
+ const events = [
171
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
172
+ JSON.stringify({
173
+ type: "source-url",
174
+ sourceId: "src_1",
175
+ url: "https://example.com",
176
+ title: "Example",
177
+ }),
178
+ JSON.stringify({
179
+ type: "source-url",
180
+ sourceId: "src_2",
181
+ url: "https://example.org",
182
+ }),
183
+ JSON.stringify({
184
+ type: "finish",
185
+ finishReason: "stop",
186
+ usage: { inputTokens: 10, outputTokens: 5 },
187
+ }),
188
+ "[DONE]",
189
+ ];
190
+
191
+ const stream = createUIMessageStream(events);
192
+ const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
193
+ const chunks = await collectChunks(decodedStream);
194
+
195
+ const sourceStarts = chunks.filter(
196
+ (c): c is AssistantStreamChunk & { type: "part-start" } =>
197
+ c.type === "part-start" && c.part.type === "source",
198
+ );
199
+ expect(sourceStarts).toHaveLength(2);
200
+ if (sourceStarts[0]?.part.type === "source") {
201
+ expect(sourceStarts[0].part.id).toBe("src_1");
202
+ expect(sourceStarts[0].part.url).toBe("https://example.com");
203
+ expect(sourceStarts[0].part.title).toBe("Example");
204
+ }
205
+ if (sourceStarts[1]?.part.type === "source") {
206
+ expect(sourceStarts[1].part.id).toBe("src_2");
207
+ expect(sourceStarts[1].part.url).toBe("https://example.org");
208
+ expect(sourceStarts[1].part.title).toBeUndefined();
209
+ }
210
+ });
211
+
212
+ it("should decode legacy source parts", async () => {
142
213
  const events = [
143
214
  JSON.stringify({ type: "start", messageId: "msg_123" }),
144
215
  JSON.stringify({
@@ -168,12 +239,44 @@ describe("UIMessageStreamDecoder", () => {
168
239
  );
169
240
  expect(sourceStart).toBeDefined();
170
241
  if (sourceStart?.part.type === "source") {
242
+ expect(sourceStart.part.id).toBe("src_1");
171
243
  expect(sourceStart.part.url).toBe("https://example.com");
172
244
  expect(sourceStart.part.title).toBe("Example");
173
245
  }
174
246
  });
175
247
 
176
248
  it("should decode file parts", async () => {
249
+ const events = [
250
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
251
+ JSON.stringify({
252
+ type: "file",
253
+ url: "https://example.com/image.png",
254
+ mediaType: "image/png",
255
+ }),
256
+ JSON.stringify({
257
+ type: "finish",
258
+ finishReason: "stop",
259
+ usage: { inputTokens: 10, outputTokens: 5 },
260
+ }),
261
+ "[DONE]",
262
+ ];
263
+
264
+ const stream = createUIMessageStream(events);
265
+ const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
266
+ const chunks = await collectChunks(decodedStream);
267
+
268
+ const fileStart = chunks.find(
269
+ (c): c is AssistantStreamChunk & { type: "part-start" } =>
270
+ c.type === "part-start" && c.part.type === "file",
271
+ );
272
+ expect(fileStart).toBeDefined();
273
+ if (fileStart?.part.type === "file") {
274
+ expect(fileStart.part.mimeType).toBe("image/png");
275
+ expect(fileStart.part.data).toBe("https://example.com/image.png");
276
+ }
277
+ });
278
+
279
+ it("should decode legacy file parts", async () => {
177
280
  const events = [
178
281
  JSON.stringify({ type: "start", messageId: "msg_123" }),
179
282
  JSON.stringify({
@@ -277,7 +380,7 @@ describe("UIMessageStreamDecoder", () => {
277
380
  JSON.stringify({ type: "start", messageId: "msg_123" }),
278
381
  JSON.stringify({ type: "start-step", messageId: "step_1" }),
279
382
  JSON.stringify({ type: "text-start", id: "text_1" }),
280
- JSON.stringify({ type: "text-delta", textDelta: "Hello" }),
383
+ JSON.stringify({ type: "text-delta", id: "text_1", delta: "Hello" }),
281
384
  JSON.stringify({ type: "text-end" }),
282
385
  JSON.stringify({
283
386
  type: "finish-step",
@@ -308,6 +411,96 @@ describe("UIMessageStreamDecoder", () => {
308
411
  );
309
412
  expect(stepFinish).toBeDefined();
310
413
  expect(stepFinish?.finishReason).toBe("stop");
414
+ expect(stepFinish?.usage).toEqual({ inputTokens: 10, outputTokens: 5 });
415
+ expect(stepFinish?.isContinued).toBe(false);
416
+ });
417
+
418
+ it("should handle the stock v6 lifecycle with bare step chunks", async () => {
419
+ const events = [
420
+ JSON.stringify({ type: "start" }),
421
+ JSON.stringify({ type: "start-step" }),
422
+ JSON.stringify({ type: "text-start", id: "text_1" }),
423
+ JSON.stringify({ type: "text-delta", id: "text_1", delta: "Hello" }),
424
+ JSON.stringify({ type: "text-end" }),
425
+ JSON.stringify({ type: "finish-step" }),
426
+ JSON.stringify({ type: "finish", finishReason: "stop" }),
427
+ "[DONE]",
428
+ ];
429
+
430
+ const stream = createUIMessageStream(events);
431
+ const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
432
+ const chunks = await collectChunks(decodedStream);
433
+
434
+ const stepStarts = chunks.filter(
435
+ (c): c is AssistantStreamChunk & { type: "step-start" } =>
436
+ c.type === "step-start",
437
+ );
438
+ expect(stepStarts).toHaveLength(2);
439
+ expect(stepStarts[0]?.messageId).toBeTruthy();
440
+ expect(stepStarts[1]?.messageId).toBe(stepStarts[0]?.messageId);
441
+
442
+ const stepFinish = chunks.find(
443
+ (c): c is AssistantStreamChunk & { type: "step-finish" } =>
444
+ c.type === "step-finish",
445
+ );
446
+ expect(stepFinish?.finishReason).toBe("unknown");
447
+ expect(stepFinish?.usage).toEqual({ inputTokens: 0, outputTokens: 0 });
448
+ expect(stepFinish?.isContinued).toBe(false);
449
+
450
+ const messageFinish = chunks.find(
451
+ (c): c is AssistantStreamChunk & { type: "message-finish" } =>
452
+ c.type === "message-finish",
453
+ );
454
+ expect(messageFinish?.finishReason).toBe("stop");
455
+ });
456
+
457
+ it("should default finishReason on a bare finish chunk", async () => {
458
+ const events = [
459
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
460
+ JSON.stringify({ type: "text-start", id: "text_1" }),
461
+ JSON.stringify({ type: "text-delta", id: "text_1", delta: "Hello" }),
462
+ JSON.stringify({ type: "text-end" }),
463
+ JSON.stringify({ type: "finish" }),
464
+ "[DONE]",
465
+ ];
466
+
467
+ const stream = createUIMessageStream(events);
468
+ const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
469
+ const chunks = await collectChunks(decodedStream);
470
+
471
+ const messageFinish = chunks.find(
472
+ (c): c is AssistantStreamChunk & { type: "message-finish" } =>
473
+ c.type === "message-finish",
474
+ );
475
+ expect(messageFinish?.finishReason).toBe("unknown");
476
+ expect(messageFinish?.usage).toEqual({ inputTokens: 0, outputTokens: 0 });
477
+ });
478
+
479
+ it("should ignore malformed legacy source and file chunks", async () => {
480
+ const events = [
481
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
482
+ JSON.stringify({ type: "source" }),
483
+ JSON.stringify({ type: "source", source: null }),
484
+ JSON.stringify({ type: "file" }),
485
+ JSON.stringify({ type: "file", file: null }),
486
+ JSON.stringify({
487
+ type: "finish",
488
+ finishReason: "stop",
489
+ usage: { inputTokens: 10, outputTokens: 5 },
490
+ }),
491
+ "[DONE]",
492
+ ];
493
+
494
+ const stream = createUIMessageStream(events);
495
+ const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
496
+ const chunks = await collectChunks(decodedStream);
497
+
498
+ const partStarts = chunks.filter(
499
+ (c) =>
500
+ c.type === "part-start" &&
501
+ (c.part.type === "source" || c.part.type === "file"),
502
+ );
503
+ expect(partStarts).toHaveLength(0);
311
504
  });
312
505
 
313
506
  it("should handle errors", async () => {
@@ -331,8 +524,8 @@ describe("UIMessageStreamDecoder", () => {
331
524
  it("should throw when stream ends without [DONE]", async () => {
332
525
  const encoder = new TextEncoder();
333
526
  const sseText =
334
- 'data: {"type":"text-delta","textDelta":"Hello"}\n\n' +
335
- 'data: {"type":"text-delta","textDelta":" world"}\n\n';
527
+ 'data: {"type":"text-delta","id":"text_1","delta":"Hello"}\n\n' +
528
+ 'data: {"type":"text-delta","id":"text_1","delta":" world"}\n\n';
336
529
 
337
530
  const stream = new ReadableStream<Uint8Array>({
338
531
  start(controller) {
@@ -348,6 +541,21 @@ describe("UIMessageStreamDecoder", () => {
348
541
  );
349
542
  });
350
543
 
544
+ it("should discard an unterminated [DONE] event", async () => {
545
+ const stream = new ReadableStream<Uint8Array>({
546
+ start(controller) {
547
+ controller.enqueue(new TextEncoder().encode("data: [DONE]\n"));
548
+ controller.close();
549
+ },
550
+ });
551
+
552
+ const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
553
+
554
+ await expect(collectChunks(decodedStream)).rejects.toThrow(
555
+ "Stream ended abruptly without receiving [DONE] marker",
556
+ );
557
+ });
558
+
351
559
  it("should ignore unknown chunk types for forward compatibility", async () => {
352
560
  const events = [
353
561
  JSON.stringify({ type: "start", messageId: "msg_123" }),
@@ -367,4 +575,115 @@ describe("UIMessageStreamDecoder", () => {
367
575
  // Should not throw, should complete successfully
368
576
  expect(chunks.some((c) => c.type === "message-finish")).toBe(true);
369
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 that are not valid JSON", async () => {
608
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
609
+ const invalidFrames = ['{"type":"te', "not json"];
610
+ const events = [
611
+ ...invalidFrames,
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.mock.calls.map((c) => c[0])).toEqual(
622
+ invalidFrames.map((f) => `Dropped invalid UIMessageStream chunk: ${f}`),
623
+ );
624
+ expect(chunks.some((c) => c.type === "text-delta")).toBe(true);
625
+ warn.mockRestore();
626
+ });
627
+
628
+ it("ignores a tool-call-delta arriving after tool-result", async () => {
629
+ const events = [
630
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
631
+ JSON.stringify({
632
+ type: "tool-call-start",
633
+ toolCallId: "call_abc",
634
+ toolName: "weather",
635
+ }),
636
+ JSON.stringify({ type: "tool-call-delta", argsText: '{"city":"NYC"}' }),
637
+ JSON.stringify({
638
+ type: "tool-result",
639
+ toolCallId: "call_abc",
640
+ result: { temp: 72 },
641
+ }),
642
+ JSON.stringify({ type: "tool-call-delta", argsText: '{"late":true}' }),
643
+ "[DONE]",
644
+ ];
645
+
646
+ const chunks = await collectChunks(
647
+ createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
648
+ );
649
+
650
+ const argDeltas = chunks.filter((c) => c.type === "text-delta");
651
+ expect(argDeltas).toHaveLength(1);
652
+ expect(chunks.some((c) => c.type === "result")).toBe(true);
653
+ });
654
+
655
+ it("keeps the active tool call writable when another call receives its result", async () => {
656
+ const events = [
657
+ JSON.stringify({
658
+ type: "tool-call-start",
659
+ toolCallId: "call_a",
660
+ toolName: "first",
661
+ }),
662
+ JSON.stringify({
663
+ type: "tool-call-start",
664
+ toolCallId: "call_b",
665
+ toolName: "second",
666
+ }),
667
+ JSON.stringify({
668
+ type: "tool-result",
669
+ toolCallId: "call_a",
670
+ result: { ok: true },
671
+ }),
672
+ JSON.stringify({ type: "tool-call-delta", argsText: '{"x":1}' }),
673
+ "[DONE]",
674
+ ];
675
+
676
+ const chunks = await collectChunks(
677
+ createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
678
+ );
679
+
680
+ const argDeltas = chunks.filter(
681
+ (c): c is AssistantStreamChunk & { type: "text-delta" } =>
682
+ c.type === "text-delta",
683
+ );
684
+ expect(argDeltas).toHaveLength(2);
685
+ const byPath = new Map(argDeltas.map((c) => [c.path[0], c.textDelta]));
686
+ expect(byPath.get(0)).toBe("{}");
687
+ expect(byPath.get(1)).toBe('{"x":1}');
688
+ });
370
689
  });
@@ -3,7 +3,10 @@ import type { ToolCallStreamController } from "../../modules/tool-call";
3
3
  import type { TextStreamController } from "../../modules/text";
4
4
  import { AssistantTransformStream } from "../../utils/stream/AssistantTransformStream";
5
5
  import { PipeableTransformStream } from "../../utils/stream/PipeableTransformStream";
6
- import { LineDecoderStream } from "../../utils/stream/LineDecoderStream";
6
+ import {
7
+ SSEEventDecoderStream,
8
+ type PipelineSSEEvent,
9
+ } from "../../utils/stream/SSEEventDecoderStream";
7
10
  import type {
8
11
  UIMessageStreamChunk,
9
12
  UIMessageStreamDataChunk,
@@ -21,72 +24,6 @@ export type UIMessageStreamDecoderOptions = {
21
24
  }) => void;
22
25
  };
23
26
 
24
- type SSEEvent = {
25
- event: string;
26
- data: string;
27
- id?: string | undefined;
28
- retry?: number | undefined;
29
- };
30
-
31
- class SSEEventStream extends TransformStream<string, SSEEvent> {
32
- constructor() {
33
- let eventBuffer: Partial<SSEEvent> = {};
34
- let dataLines: string[] = [];
35
-
36
- super({
37
- start() {
38
- eventBuffer = {};
39
- dataLines = [];
40
- },
41
- transform(line, controller) {
42
- if (line.startsWith(":")) return;
43
-
44
- if (line === "") {
45
- if (dataLines.length > 0) {
46
- controller.enqueue({
47
- event: eventBuffer.event || "message",
48
- data: dataLines.join("\n"),
49
- id: eventBuffer.id,
50
- retry: eventBuffer.retry,
51
- });
52
- }
53
- eventBuffer = {};
54
- dataLines = [];
55
- return;
56
- }
57
-
58
- const [field, ...rest] = line.split(":");
59
- const value = rest.join(":").trimStart();
60
-
61
- switch (field) {
62
- case "event":
63
- eventBuffer.event = value;
64
- break;
65
- case "data":
66
- dataLines.push(value);
67
- break;
68
- case "id":
69
- eventBuffer.id = value;
70
- break;
71
- case "retry":
72
- eventBuffer.retry = Number(value);
73
- break;
74
- }
75
- },
76
- flush(controller) {
77
- if (dataLines.length > 0) {
78
- controller.enqueue({
79
- event: eventBuffer.event || "message",
80
- data: dataLines.join("\n"),
81
- id: eventBuffer.id,
82
- retry: eventBuffer.retry,
83
- });
84
- }
85
- },
86
- });
87
- }
88
- }
89
-
90
27
  const isDataChunk = (
91
28
  chunk: UIMessageStreamChunk,
92
29
  ): chunk is UIMessageStreamDataChunk => chunk.type.startsWith("data-");
@@ -212,6 +149,9 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
212
149
  `Encountered tool result with unknown id: ${chunk.toolCallId}`,
213
150
  );
214
151
  }
152
+ if (toolCallController.argsText === activeToolCallArgsText) {
153
+ activeToolCallArgsText = undefined;
154
+ }
215
155
  toolCallController.setResponse({
216
156
  result: chunk.result,
217
157
  isError: chunk.isError ?? false,
@@ -271,10 +211,9 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
271
211
 
272
212
  return readable
273
213
  .pipeThrough(new TextDecoderStream())
274
- .pipeThrough(new LineDecoderStream())
275
- .pipeThrough(new SSEEventStream())
214
+ .pipeThrough(new SSEEventDecoderStream())
276
215
  .pipeThrough(
277
- new TransformStream<SSEEvent, UIMessageStreamChunk>({
216
+ new TransformStream<PipelineSSEEvent, UIMessageStreamChunk>({
278
217
  transform(event, controller) {
279
218
  if (event.event !== "message") {
280
219
  throw new Error(`Unknown SSE event type: ${event.event}`);
@@ -286,7 +225,78 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
286
225
  return;
287
226
  }
288
227
 
289
- controller.enqueue(JSON.parse(event.data));
228
+ let chunk;
229
+ try {
230
+ chunk = JSON.parse(event.data);
231
+ } catch {
232
+ chunk = undefined;
233
+ }
234
+ if (
235
+ typeof chunk !== "object" ||
236
+ chunk === null ||
237
+ Array.isArray(chunk) ||
238
+ typeof chunk.type !== "string"
239
+ ) {
240
+ console.warn(
241
+ `Dropped invalid UIMessageStream chunk: ${event.data.slice(0, 200)}`,
242
+ );
243
+ return;
244
+ }
245
+ if (
246
+ chunk.type === "text-delta" &&
247
+ chunk.textDelta === undefined
248
+ ) {
249
+ const { delta, ...rest } = chunk;
250
+ controller.enqueue({ ...rest, textDelta: delta ?? "" });
251
+ return;
252
+ }
253
+ if (chunk.type === "start") {
254
+ controller.enqueue({
255
+ ...chunk,
256
+ messageId: chunk.messageId ?? generateId(),
257
+ });
258
+ return;
259
+ }
260
+ if (chunk.type === "source-url") {
261
+ controller.enqueue({
262
+ type: "source",
263
+ source: {
264
+ sourceType: "url",
265
+ id: chunk.sourceId,
266
+ url: chunk.url,
267
+ ...(chunk.title && { title: chunk.title }),
268
+ },
269
+ });
270
+ return;
271
+ }
272
+ if (chunk.type === "source" && chunk.source == null) return;
273
+ if (chunk.type === "file" && chunk.file == null) {
274
+ if (chunk.url === undefined) return;
275
+ controller.enqueue({
276
+ type: "file",
277
+ file: { mimeType: chunk.mediaType, data: chunk.url },
278
+ });
279
+ return;
280
+ }
281
+ if (chunk.type === "finish-step") {
282
+ controller.enqueue({
283
+ ...chunk,
284
+ finishReason: chunk.finishReason ?? "unknown",
285
+ usage: chunk.usage ?? { inputTokens: 0, outputTokens: 0 },
286
+ isContinued: chunk.isContinued ?? false,
287
+ });
288
+ return;
289
+ }
290
+ if (chunk.type === "finish") {
291
+ controller.enqueue({
292
+ ...chunk,
293
+ finishReason: chunk.finishReason ?? "unknown",
294
+ usage: chunk.usage ?? { inputTokens: 0, outputTokens: 0 },
295
+ });
296
+ return;
297
+ }
298
+
299
+ controller.enqueue(chunk);
290
300
  },
291
301
  flush() {
292
302
  if (!receivedDone) {
@@ -51,6 +51,7 @@ export class ToolExecutionStream extends PipeableTransformStream<
51
51
  string,
52
52
  ToolCallReaderImpl<ReadonlyJSONObject, ReadonlyJSONValue>
53
53
  >();
54
+ const toolCallIdsWithBackendResult = new Set<string>();
54
55
 
55
56
  super((readable) => {
56
57
  const transform = new TransformStream<
@@ -109,6 +110,7 @@ export class ToolExecutionStream extends PipeableTransformStream<
109
110
  modelContent: chunk.modelContent,
110
111
  }),
111
112
  );
113
+ toolCallIdsWithBackendResult.add(toolCallId);
112
114
  break;
113
115
  }
114
116
  case "tool-call-args-text-finish": {
@@ -123,6 +125,11 @@ export class ToolExecutionStream extends PipeableTransformStream<
123
125
  // resolve. Awaited so the close settles before the writer is reused.
124
126
  await streamController.finishArgsText();
125
127
 
128
+ // A backend result is authoritative. Closing the args stream still
129
+ // emits this finish chunk, but must not parse stale/incomplete args,
130
+ // execute the frontend tool, or enqueue a second result.
131
+ if (toolCallIdsWithBackendResult.has(toolCallId)) break;
132
+
126
133
  let isExecuting = false;
127
134
  const promise = withPromiseOrValue(
128
135
  () => {
@@ -206,10 +213,13 @@ export class ToolExecutionStream extends PipeableTransformStream<
206
213
  toolCallPromise.then(() => {
207
214
  toolCallPromises.delete(toolCallId);
208
215
  toolCallControllers.delete(toolCallId);
216
+ toolCallIdsWithBackendResult.delete(toolCallId);
209
217
 
210
218
  controller.enqueue(chunk);
211
219
  });
212
220
  } else {
221
+ toolCallControllers.delete(toolCallId);
222
+ toolCallIdsWithBackendResult.delete(toolCallId);
213
223
  controller.enqueue(chunk);
214
224
  }
215
225
  }
@@ -17,6 +17,56 @@ const createDelayedTool = (delay: number, result?: string): Tool => ({
17
17
  });
18
18
 
19
19
  describe("unstable_runPendingTools", () => {
20
+ it("removes the abort listener after tool execution settles", async () => {
21
+ const abortController = new AbortController();
22
+ const addEventListener = vi.spyOn(
23
+ abortController.signal,
24
+ "addEventListener",
25
+ );
26
+ const removeEventListener = vi.spyOn(
27
+ abortController.signal,
28
+ "removeEventListener",
29
+ );
30
+ const message: AssistantMessage = {
31
+ role: "assistant",
32
+ status: { type: "requires-action", reason: "tool-calls" },
33
+ parts: [
34
+ {
35
+ type: "tool-call",
36
+ toolCallId: "1",
37
+ toolName: "tool",
38
+ args: {},
39
+ } as ToolCallPart,
40
+ ],
41
+ content: [],
42
+ metadata: {
43
+ unstable_state: {},
44
+ unstable_data: [],
45
+ unstable_annotations: [],
46
+ steps: [],
47
+ custom: {},
48
+ },
49
+ };
50
+
51
+ await unstable_runPendingTools(
52
+ message,
53
+ {
54
+ tool: {
55
+ parameters: { type: "object", properties: {} },
56
+ execute: async () => "done",
57
+ },
58
+ },
59
+ abortController.signal,
60
+ async () => {},
61
+ );
62
+
63
+ expect(addEventListener).toHaveBeenCalledOnce();
64
+ expect(removeEventListener).toHaveBeenCalledWith(
65
+ "abort",
66
+ addEventListener.mock.calls[0]![1],
67
+ );
68
+ });
69
+
20
70
  describe("parallel execution", () => {
21
71
  it("should run tool calls in parallel", async () => {
22
72
  const tool1 = createDelayedTool(100, "Tool 1");
@@ -59,9 +59,10 @@ function getToolResponse(
59
59
 
60
60
  // Create abort promise that resolves after 2 microtasks
61
61
  // This gives tools that handle abort a chance to win the race
62
+ let onAbort!: () => void;
62
63
  const abortPromise = new Promise<ToolResponse<ReadonlyJSONValue>>(
63
64
  (resolve) => {
64
- const onAbort = () => {
65
+ onAbort = () => {
65
66
  queueMicrotask(() => {
66
67
  queueMicrotask(() => {
67
68
  resolve(
@@ -116,7 +117,11 @@ function getToolResponse(
116
117
  return response;
117
118
  })();
118
119
 
119
- return Promise.race([executePromise, abortPromise]);
120
+ try {
121
+ return await Promise.race([executePromise, abortPromise]);
122
+ } finally {
123
+ abortSignal.removeEventListener("abort", onAbort);
124
+ }
120
125
  };
121
126
 
122
127
  return getResult(tool.execute);