assistant-stream 0.3.25 → 0.3.26

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 (146) 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 +24 -12
  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 +6 -1
  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/modules/assistant-stream.d.ts +13 -7
  18. package/dist/core/modules/assistant-stream.d.ts.map +1 -1
  19. package/dist/core/modules/text.d.ts +0 -1
  20. package/dist/core/modules/text.d.ts.map +1 -1
  21. package/dist/core/modules/tool-call.d.ts +0 -1
  22. package/dist/core/modules/tool-call.d.ts.map +1 -1
  23. package/dist/core/object/ObjectStreamAccumulator.d.ts +1 -1
  24. package/dist/core/object/ObjectStreamAccumulator.d.ts.map +1 -1
  25. package/dist/core/object/ObjectStreamResponse.d.ts +0 -1
  26. package/dist/core/object/ObjectStreamResponse.d.ts.map +1 -1
  27. package/dist/core/object/ObjectStreamResponse.js +1 -1
  28. package/dist/core/object/ObjectStreamResponse.js.map +1 -1
  29. package/dist/core/object/createObjectStream.d.ts +2 -5
  30. package/dist/core/object/createObjectStream.d.ts.map +1 -1
  31. package/dist/core/object/types.d.ts +1 -0
  32. package/dist/core/object/types.d.ts.map +1 -1
  33. package/dist/core/serialization/PlainText.d.ts +0 -1
  34. package/dist/core/serialization/PlainText.d.ts.map +1 -1
  35. package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts +0 -1
  36. package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts.map +1 -1
  37. package/dist/core/serialization/assistant-transport/AssistantTransport.js +2 -52
  38. package/dist/core/serialization/assistant-transport/AssistantTransport.js.map +1 -1
  39. package/dist/core/serialization/data-stream/DataStream.d.ts +0 -1
  40. package/dist/core/serialization/data-stream/DataStream.d.ts.map +1 -1
  41. package/dist/core/serialization/data-stream/chunk-types.d.ts +1 -2
  42. package/dist/core/serialization/data-stream/chunk-types.d.ts.map +1 -1
  43. package/dist/core/serialization/data-stream/serialization.d.ts +0 -1
  44. package/dist/core/serialization/data-stream/serialization.d.ts.map +1 -1
  45. package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts +0 -1
  46. package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
  47. package/dist/core/serialization/ui-message-stream/UIMessageStream.js +66 -53
  48. package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
  49. package/dist/core/serialization/ui-message-stream/chunk-types.d.ts +0 -1
  50. package/dist/core/serialization/ui-message-stream/chunk-types.d.ts.map +1 -1
  51. package/dist/core/tool/ToolCallReader.d.ts +1 -0
  52. package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
  53. package/dist/core/tool/ToolExecutionStream.d.ts +0 -1
  54. package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
  55. package/dist/core/tool/ToolResponse.d.ts +6 -4
  56. package/dist/core/tool/ToolResponse.d.ts.map +1 -1
  57. package/dist/core/tool/schema-utils.d.ts +0 -1
  58. package/dist/core/tool/schema-utils.d.ts.map +1 -1
  59. package/dist/core/tool/tool-types.d.ts +71 -33
  60. package/dist/core/tool/tool-types.d.ts.map +1 -1
  61. package/dist/core/tool/toolResultStream.d.ts +3 -2
  62. package/dist/core/tool/toolResultStream.d.ts.map +1 -1
  63. package/dist/core/tool/type-path-utils.d.ts +2 -2
  64. package/dist/core/tool/type-path-utils.d.ts.map +1 -1
  65. package/dist/core/utils/Counter.d.ts.map +1 -1
  66. package/dist/core/utils/generateId.d.ts.map +1 -1
  67. package/dist/core/utils/stream/AssistantMetaTransformStream.d.ts +0 -1
  68. package/dist/core/utils/stream/AssistantMetaTransformStream.d.ts.map +1 -1
  69. package/dist/core/utils/stream/AssistantTransformStream.d.ts +0 -1
  70. package/dist/core/utils/stream/AssistantTransformStream.d.ts.map +1 -1
  71. package/dist/core/utils/stream/LineDecoderStream.d.ts +1 -0
  72. package/dist/core/utils/stream/LineDecoderStream.d.ts.map +1 -1
  73. package/dist/core/utils/stream/LineDecoderStream.js +19 -6
  74. package/dist/core/utils/stream/LineDecoderStream.js.map +1 -1
  75. package/dist/core/utils/stream/PipeableTransformStream.d.ts.map +1 -1
  76. package/dist/core/utils/stream/SSE.d.ts +0 -1
  77. package/dist/core/utils/stream/SSE.d.ts.map +1 -1
  78. package/dist/core/utils/stream/SSE.js +2 -52
  79. package/dist/core/utils/stream/SSE.js.map +1 -1
  80. package/dist/core/utils/stream/SSEEventDecoder.d.ts +27 -0
  81. package/dist/core/utils/stream/SSEEventDecoder.d.ts.map +1 -0
  82. package/dist/core/utils/stream/SSEEventDecoder.js +84 -0
  83. package/dist/core/utils/stream/SSEEventDecoder.js.map +1 -0
  84. package/dist/core/utils/stream/SSEEventDecoderStream.d.ts +13 -0
  85. package/dist/core/utils/stream/SSEEventDecoderStream.d.ts.map +1 -0
  86. package/dist/core/utils/stream/SSEEventDecoderStream.js +24 -0
  87. package/dist/core/utils/stream/SSEEventDecoderStream.js.map +1 -0
  88. package/dist/core/utils/stream/UnderlyingReadable.d.ts.map +1 -1
  89. package/dist/core/utils/stream/merge.d.ts +0 -1
  90. package/dist/core/utils/stream/merge.d.ts.map +1 -1
  91. package/dist/core/utils/stream/path-utils.d.ts +0 -1
  92. package/dist/core/utils/stream/path-utils.d.ts.map +1 -1
  93. package/dist/core/utils/types.d.ts +16 -8
  94. package/dist/core/utils/types.d.ts.map +1 -1
  95. package/dist/core/utils/withPromiseOrValue.d.ts.map +1 -1
  96. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +3 -3
  97. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -1
  98. package/dist/resumable/ResumableStreamContext.d.ts +0 -1
  99. package/dist/resumable/ResumableStreamContext.d.ts.map +1 -1
  100. package/dist/resumable/constants.d.ts.map +1 -1
  101. package/dist/resumable/createResumableAssistantStreamResponse.d.ts +6 -4
  102. package/dist/resumable/createResumableAssistantStreamResponse.d.ts.map +1 -1
  103. package/dist/resumable/errors.d.ts.map +1 -1
  104. package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts +0 -1
  105. package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts.map +1 -1
  106. package/dist/resumable/stores/InMemoryResumableStreamStore.js +2 -1
  107. package/dist/resumable/stores/InMemoryResumableStreamStore.js.map +1 -1
  108. package/dist/resumable/stores/ioredis.d.ts +0 -1
  109. package/dist/resumable/stores/ioredis.d.ts.map +1 -1
  110. package/dist/resumable/stores/redis-impl.d.ts +2 -2
  111. package/dist/resumable/stores/redis-impl.d.ts.map +1 -1
  112. package/dist/resumable/stores/redis.d.ts +0 -1
  113. package/dist/resumable/stores/redis.d.ts.map +1 -1
  114. package/dist/resumable/types.d.ts.map +1 -1
  115. package/dist/utils/AsyncIterableStream.d.ts.map +1 -1
  116. package/dist/utils/json/fix-json.d.ts.map +1 -1
  117. package/dist/utils/json/json-value.d.ts.map +1 -1
  118. package/dist/utils/json/parse-partial-json-object.d.ts +0 -1
  119. package/dist/utils/json/parse-partial-json-object.d.ts.map +1 -1
  120. package/dist/utils/promiseWithResolvers.d.ts.map +1 -1
  121. package/dist/utils.d.ts +2 -1
  122. package/dist/utils.js +2 -1
  123. package/package.json +5 -5
  124. package/src/core/AssistantStreamChunk.ts +2 -0
  125. package/src/core/accumulators/assistant-message-accumulator.test.ts +63 -0
  126. package/src/core/accumulators/assistant-message-accumulator.ts +15 -1
  127. package/src/core/object/ObjectStream.test.ts +20 -0
  128. package/src/core/object/ObjectStreamResponse.test.ts +32 -0
  129. package/src/core/object/ObjectStreamResponse.ts +6 -1
  130. package/src/core/serialization/assistant-transport/AssistantTransport.test.ts +41 -0
  131. package/src/core/serialization/assistant-transport/AssistantTransport.ts +6 -70
  132. package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +212 -4
  133. package/src/core/serialization/ui-message-stream/UIMessageStream.ts +62 -71
  134. package/src/core/utils/stream/LineDecoderStream.test.ts +26 -2
  135. package/src/core/utils/stream/LineDecoderStream.ts +21 -11
  136. package/src/core/utils/stream/SSE.ts +6 -70
  137. package/src/core/utils/stream/SSEEventDecoder.test.ts +146 -0
  138. package/src/core/utils/stream/SSEEventDecoder.ts +109 -0
  139. package/src/core/utils/stream/SSEEventDecoderStream.test.ts +42 -0
  140. package/src/core/utils/stream/SSEEventDecoderStream.ts +34 -0
  141. package/src/utils.ts +4 -0
  142. package/dist/utils/json/is-json.d.ts +0 -9
  143. package/dist/utils/json/is-json.d.ts.map +0 -1
  144. package/dist/utils/json/is-json.js +0 -17
  145. package/dist/utils/json/is-json.js.map +0 -1
  146. package/src/utils/json/is-json.ts +0 -43
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fromObjectStreamResponse } from "./ObjectStreamResponse";
3
+
4
+ const createResponse = (contentType?: string) => {
5
+ const headers = new Headers({
6
+ "Assistant-Stream-Format": "object-stream/v0",
7
+ });
8
+ if (contentType) headers.set("Content-Type", contentType);
9
+
10
+ return new Response(new Uint8Array(), { headers });
11
+ };
12
+
13
+ describe("fromObjectStreamResponse", () => {
14
+ it.each([
15
+ "text/event-stream",
16
+ "text/event-stream; charset=utf-8",
17
+ "Text/Event-Stream; Charset=UTF-8",
18
+ ])("accepts the event-stream content type %s", (contentType) => {
19
+ expect(() =>
20
+ fromObjectStreamResponse(createResponse(contentType)),
21
+ ).not.toThrow();
22
+ });
23
+
24
+ it.each([undefined, "application/json", "text/event-streaming"])(
25
+ "rejects the content type %s",
26
+ (contentType) => {
27
+ expect(() =>
28
+ fromObjectStreamResponse(createResponse(contentType)),
29
+ ).toThrow("Response is not an event stream");
30
+ },
31
+ );
32
+ });
@@ -99,7 +99,12 @@ export const fromObjectStreamResponse = (
99
99
  if (!response.ok)
100
100
  throw new Error(`Response failed, status ${response.status}`);
101
101
  if (!response.body) throw new Error("Response body is null");
102
- if (response.headers.get("Content-Type") !== "text/event-stream") {
102
+ const mediaType = response.headers
103
+ .get("Content-Type")
104
+ ?.split(";", 1)[0]
105
+ ?.trim()
106
+ .toLowerCase();
107
+ if (mediaType !== "text/event-stream") {
103
108
  throw new Error("Response is not an event stream");
104
109
  }
105
110
  if (response.headers.get("Assistant-Stream-Format") !== "object-stream/v0") {
@@ -186,6 +186,21 @@ describe("AssistantTransportDecoder", () => {
186
186
  });
187
187
  });
188
188
 
189
+ it("should discard an unterminated [DONE] event", async () => {
190
+ const stream = new ReadableStream<Uint8Array>({
191
+ start(controller) {
192
+ controller.enqueue(new TextEncoder().encode("data: [DONE]\n"));
193
+ controller.close();
194
+ },
195
+ });
196
+
197
+ const decodedStream = stream.pipeThrough(new AssistantTransportDecoder());
198
+
199
+ await expect(collectChunks(decodedStream)).rejects.toThrow(
200
+ "Stream ended abruptly without receiving [DONE] marker",
201
+ );
202
+ });
203
+
189
204
  it("should throw error when stream ends without [DONE]", async () => {
190
205
  // Manually create an SSE stream without [DONE]
191
206
  const sseText =
@@ -207,4 +222,30 @@ describe("AssistantTransportDecoder", () => {
207
222
  "Stream ended abruptly without receiving [DONE] marker",
208
223
  );
209
224
  });
225
+
226
+ it("round-trips error chunks with code and severity", async () => {
227
+ const originalChunks: AssistantStreamChunk[] = [
228
+ {
229
+ type: "error",
230
+ path: [],
231
+ error: "rate limited",
232
+ code: "provider",
233
+ severity: "warning",
234
+ },
235
+ ];
236
+
237
+ const stream = new ReadableStream<AssistantStreamChunk>({
238
+ start(controller) {
239
+ for (const chunk of originalChunks) {
240
+ controller.enqueue(chunk);
241
+ }
242
+ controller.close();
243
+ },
244
+ });
245
+
246
+ const decodedStream = await encodeAndDecode(stream);
247
+ const decodedChunks = await collectChunks(decodedStream);
248
+
249
+ expect(decodedChunks).toEqual(originalChunks);
250
+ });
210
251
  });
@@ -1,6 +1,9 @@
1
1
  import type { AssistantStreamChunk } from "../../AssistantStreamChunk";
2
2
  import { PipeableTransformStream } from "../../utils/stream/PipeableTransformStream";
3
- import { LineDecoderStream } from "../../utils/stream/LineDecoderStream";
3
+ import {
4
+ SSEEventDecoderStream,
5
+ type PipelineSSEEvent,
6
+ } from "../../utils/stream/SSEEventDecoderStream";
4
7
  import type { AssistantStreamEncoder } from "../../AssistantStream";
5
8
 
6
9
  /**
@@ -35,72 +38,6 @@ export class AssistantTransportEncoder
35
38
  }
36
39
  }
37
40
 
38
- type SSEEvent = {
39
- event: string;
40
- data: string;
41
- id?: string | undefined;
42
- retry?: number | undefined;
43
- };
44
-
45
- class SSEEventStream extends TransformStream<string, SSEEvent> {
46
- constructor() {
47
- let eventBuffer: Partial<SSEEvent> = {};
48
- let dataLines: string[] = [];
49
-
50
- super({
51
- start() {
52
- eventBuffer = {};
53
- dataLines = [];
54
- },
55
- transform(line, controller) {
56
- if (line.startsWith(":")) return; // Ignore comments
57
-
58
- if (line === "") {
59
- if (dataLines.length > 0) {
60
- controller.enqueue({
61
- event: eventBuffer.event || "message",
62
- data: dataLines.join("\n"),
63
- id: eventBuffer.id,
64
- retry: eventBuffer.retry,
65
- });
66
- }
67
- eventBuffer = {};
68
- dataLines = [];
69
- return;
70
- }
71
-
72
- const [field, ...rest] = line.split(":");
73
- const value = rest.join(":").trimStart();
74
-
75
- switch (field) {
76
- case "event":
77
- eventBuffer.event = value;
78
- break;
79
- case "data":
80
- dataLines.push(value);
81
- break;
82
- case "id":
83
- eventBuffer.id = value;
84
- break;
85
- case "retry":
86
- eventBuffer.retry = Number(value);
87
- break;
88
- }
89
- },
90
- flush(controller) {
91
- if (dataLines.length > 0) {
92
- controller.enqueue({
93
- event: eventBuffer.event || "message",
94
- data: dataLines.join("\n"),
95
- id: eventBuffer.id,
96
- retry: eventBuffer.retry,
97
- });
98
- }
99
- },
100
- });
101
- }
102
- }
103
-
104
41
  /**
105
42
  * AssistantTransportDecoder decodes SSE format into AssistantStreamChunks.
106
43
  * It stops decoding when it encounters [DONE].
@@ -115,10 +52,9 @@ export class AssistantTransportDecoder extends PipeableTransformStream<
115
52
 
116
53
  return readable
117
54
  .pipeThrough(new TextDecoderStream())
118
- .pipeThrough(new LineDecoderStream())
119
- .pipeThrough(new SSEEventStream())
55
+ .pipeThrough(new SSEEventDecoderStream())
120
56
  .pipeThrough(
121
- new TransformStream<SSEEvent, AssistantStreamChunk>({
57
+ new TransformStream<PipelineSSEEvent, AssistantStreamChunk>({
122
58
  transform(event, controller) {
123
59
  switch (event.event) {
124
60
  case "message":
@@ -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" }),
@@ -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-");
@@ -271,10 +208,9 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
271
208
 
272
209
  return readable
273
210
  .pipeThrough(new TextDecoderStream())
274
- .pipeThrough(new LineDecoderStream())
275
- .pipeThrough(new SSEEventStream())
211
+ .pipeThrough(new SSEEventDecoderStream())
276
212
  .pipeThrough(
277
- new TransformStream<SSEEvent, UIMessageStreamChunk>({
213
+ new TransformStream<PipelineSSEEvent, UIMessageStreamChunk>({
278
214
  transform(event, controller) {
279
215
  if (event.event !== "message") {
280
216
  throw new Error(`Unknown SSE event type: ${event.event}`);
@@ -286,7 +222,62 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
286
222
  return;
287
223
  }
288
224
 
289
- controller.enqueue(JSON.parse(event.data));
225
+ const chunk = JSON.parse(event.data);
226
+ if (
227
+ chunk.type === "text-delta" &&
228
+ chunk.textDelta === undefined
229
+ ) {
230
+ const { delta, ...rest } = chunk;
231
+ controller.enqueue({ ...rest, textDelta: delta ?? "" });
232
+ return;
233
+ }
234
+ if (chunk.type === "start") {
235
+ controller.enqueue({
236
+ ...chunk,
237
+ messageId: chunk.messageId ?? generateId(),
238
+ });
239
+ return;
240
+ }
241
+ if (chunk.type === "source-url") {
242
+ controller.enqueue({
243
+ type: "source",
244
+ source: {
245
+ sourceType: "url",
246
+ id: chunk.sourceId,
247
+ url: chunk.url,
248
+ ...(chunk.title && { title: chunk.title }),
249
+ },
250
+ });
251
+ return;
252
+ }
253
+ if (chunk.type === "source" && chunk.source == null) return;
254
+ if (chunk.type === "file" && chunk.file == null) {
255
+ if (chunk.url === undefined) return;
256
+ controller.enqueue({
257
+ type: "file",
258
+ file: { mimeType: chunk.mediaType, data: chunk.url },
259
+ });
260
+ return;
261
+ }
262
+ if (chunk.type === "finish-step") {
263
+ controller.enqueue({
264
+ ...chunk,
265
+ finishReason: chunk.finishReason ?? "unknown",
266
+ usage: chunk.usage ?? { inputTokens: 0, outputTokens: 0 },
267
+ isContinued: chunk.isContinued ?? false,
268
+ });
269
+ return;
270
+ }
271
+ if (chunk.type === "finish") {
272
+ controller.enqueue({
273
+ ...chunk,
274
+ finishReason: chunk.finishReason ?? "unknown",
275
+ usage: chunk.usage ?? { inputTokens: 0, outputTokens: 0 },
276
+ });
277
+ return;
278
+ }
279
+
280
+ controller.enqueue(chunk);
290
281
  },
291
282
  flush() {
292
283
  if (!receivedDone) {
@@ -40,6 +40,22 @@ describe("LineDecoderStream", () => {
40
40
  expect(lines).toEqual(["line1", "line2", "line3"]);
41
41
  });
42
42
 
43
+ it("should split lines on CR (\\r)", async () => {
44
+ const stream = createTextStream(["line1\rline2\rline3\r"]);
45
+ const lines = await collectLines(
46
+ stream.pipeThrough(new LineDecoderStream()),
47
+ );
48
+ expect(lines).toEqual(["line1", "line2", "line3"]);
49
+ });
50
+
51
+ it("should handle CR line endings split across chunks", async () => {
52
+ const stream = createTextStream(["line1\r", "line2\r", "line3\r"]);
53
+ const lines = await collectLines(
54
+ stream.pipeThrough(new LineDecoderStream()),
55
+ );
56
+ expect(lines).toEqual(["line1", "line2", "line3"]);
57
+ });
58
+
43
59
  it("should handle CRLF split across chunks", async () => {
44
60
  // The \r is at the end of one chunk and \n at the start of the next
45
61
  const stream = createTextStream(["line1\r", "\nline2\r\n"]);
@@ -65,8 +81,16 @@ describe("LineDecoderStream", () => {
65
81
  expect(lines).toEqual(["line1", "", "line2"]);
66
82
  });
67
83
 
68
- it("should handle mixed LF and CRLF", async () => {
69
- const stream = createTextStream(["line1\nline2\r\nline3\n"]);
84
+ it("should handle empty lines with CR", async () => {
85
+ const stream = createTextStream(["line1\r\rline2\r"]);
86
+ const lines = await collectLines(
87
+ stream.pipeThrough(new LineDecoderStream()),
88
+ );
89
+ expect(lines).toEqual(["line1", "", "line2"]);
90
+ });
91
+
92
+ it("should handle mixed standard line endings", async () => {
93
+ const stream = createTextStream(["line1\nline2\r\nline3\r"]);
70
94
  const lines = await collectLines(
71
95
  stream.pipeThrough(new LineDecoderStream()),
72
96
  );