assistant-stream 0.3.24 → 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.
- package/README.md +4 -1
- package/dist/core/AssistantStream.d.ts +0 -1
- package/dist/core/AssistantStream.d.ts.map +1 -1
- package/dist/core/AssistantStreamChunk.d.ts +24 -12
- package/dist/core/AssistantStreamChunk.d.ts.map +1 -1
- package/dist/core/accumulators/AssistantMessageStream.d.ts +0 -1
- package/dist/core/accumulators/AssistantMessageStream.d.ts.map +1 -1
- package/dist/core/accumulators/TimingTracker.d.ts +0 -1
- package/dist/core/accumulators/TimingTracker.d.ts.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.d.ts +3 -9
- package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.js +6 -1
- package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
- package/dist/core/converters/toGenericMessages.d.ts.map +1 -1
- package/dist/core/converters/toGenericMessages.js +2 -1
- package/dist/core/converters/toGenericMessages.js.map +1 -1
- package/dist/core/modules/assistant-stream.d.ts +13 -7
- package/dist/core/modules/assistant-stream.d.ts.map +1 -1
- package/dist/core/modules/text.d.ts +0 -1
- package/dist/core/modules/text.d.ts.map +1 -1
- package/dist/core/modules/tool-call.d.ts +0 -1
- package/dist/core/modules/tool-call.d.ts.map +1 -1
- package/dist/core/object/ObjectStreamAccumulator.d.ts +1 -1
- package/dist/core/object/ObjectStreamAccumulator.d.ts.map +1 -1
- package/dist/core/object/ObjectStreamResponse.d.ts +0 -1
- package/dist/core/object/ObjectStreamResponse.d.ts.map +1 -1
- package/dist/core/object/ObjectStreamResponse.js +1 -1
- package/dist/core/object/ObjectStreamResponse.js.map +1 -1
- package/dist/core/object/createObjectStream.d.ts +2 -5
- package/dist/core/object/createObjectStream.d.ts.map +1 -1
- package/dist/core/object/types.d.ts +1 -0
- package/dist/core/object/types.d.ts.map +1 -1
- package/dist/core/serialization/PlainText.d.ts +0 -1
- package/dist/core/serialization/PlainText.d.ts.map +1 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts +0 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts.map +1 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.js +2 -52
- package/dist/core/serialization/assistant-transport/AssistantTransport.js.map +1 -1
- package/dist/core/serialization/data-stream/DataStream.d.ts +0 -1
- package/dist/core/serialization/data-stream/DataStream.d.ts.map +1 -1
- package/dist/core/serialization/data-stream/chunk-types.d.ts +1 -2
- package/dist/core/serialization/data-stream/chunk-types.d.ts.map +1 -1
- package/dist/core/serialization/data-stream/serialization.d.ts +0 -1
- package/dist/core/serialization/data-stream/serialization.d.ts.map +1 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts +0 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.js +66 -53
- package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
- package/dist/core/serialization/ui-message-stream/chunk-types.d.ts +0 -1
- package/dist/core/serialization/ui-message-stream/chunk-types.d.ts.map +1 -1
- package/dist/core/tool/ToolCallReader.d.ts +1 -0
- package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
- package/dist/core/tool/ToolExecutionStream.d.ts +0 -1
- package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
- package/dist/core/tool/ToolResponse.d.ts +6 -4
- package/dist/core/tool/ToolResponse.d.ts.map +1 -1
- package/dist/core/tool/schema-utils.d.ts +0 -1
- package/dist/core/tool/schema-utils.d.ts.map +1 -1
- package/dist/core/tool/tool-types.d.ts +71 -31
- package/dist/core/tool/tool-types.d.ts.map +1 -1
- package/dist/core/tool/toolResultStream.d.ts +3 -2
- package/dist/core/tool/toolResultStream.d.ts.map +1 -1
- package/dist/core/tool/type-path-utils.d.ts +2 -2
- package/dist/core/tool/type-path-utils.d.ts.map +1 -1
- package/dist/core/utils/Counter.d.ts.map +1 -1
- package/dist/core/utils/generateId.d.ts.map +1 -1
- package/dist/core/utils/stream/AssistantMetaTransformStream.d.ts +0 -1
- package/dist/core/utils/stream/AssistantMetaTransformStream.d.ts.map +1 -1
- package/dist/core/utils/stream/AssistantTransformStream.d.ts +0 -1
- package/dist/core/utils/stream/AssistantTransformStream.d.ts.map +1 -1
- package/dist/core/utils/stream/LineDecoderStream.d.ts +1 -0
- package/dist/core/utils/stream/LineDecoderStream.d.ts.map +1 -1
- package/dist/core/utils/stream/LineDecoderStream.js +19 -6
- package/dist/core/utils/stream/LineDecoderStream.js.map +1 -1
- package/dist/core/utils/stream/PipeableTransformStream.d.ts.map +1 -1
- package/dist/core/utils/stream/SSE.d.ts +0 -1
- package/dist/core/utils/stream/SSE.d.ts.map +1 -1
- package/dist/core/utils/stream/SSE.js +2 -52
- package/dist/core/utils/stream/SSE.js.map +1 -1
- package/dist/core/utils/stream/SSEEventDecoder.d.ts +27 -0
- package/dist/core/utils/stream/SSEEventDecoder.d.ts.map +1 -0
- package/dist/core/utils/stream/SSEEventDecoder.js +84 -0
- package/dist/core/utils/stream/SSEEventDecoder.js.map +1 -0
- package/dist/core/utils/stream/SSEEventDecoderStream.d.ts +13 -0
- package/dist/core/utils/stream/SSEEventDecoderStream.d.ts.map +1 -0
- package/dist/core/utils/stream/SSEEventDecoderStream.js +24 -0
- package/dist/core/utils/stream/SSEEventDecoderStream.js.map +1 -0
- package/dist/core/utils/stream/UnderlyingReadable.d.ts.map +1 -1
- package/dist/core/utils/stream/merge.d.ts +0 -1
- package/dist/core/utils/stream/merge.d.ts.map +1 -1
- package/dist/core/utils/stream/path-utils.d.ts +0 -1
- package/dist/core/utils/stream/path-utils.d.ts.map +1 -1
- package/dist/core/utils/types.d.ts +16 -8
- package/dist/core/utils/types.d.ts.map +1 -1
- package/dist/core/utils/withPromiseOrValue.d.ts.map +1 -1
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +3 -3
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -1
- package/dist/resumable/ResumableStreamContext.d.ts +0 -1
- package/dist/resumable/ResumableStreamContext.d.ts.map +1 -1
- package/dist/resumable/constants.d.ts.map +1 -1
- package/dist/resumable/createResumableAssistantStreamResponse.d.ts +6 -4
- package/dist/resumable/createResumableAssistantStreamResponse.d.ts.map +1 -1
- package/dist/resumable/errors.d.ts.map +1 -1
- package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts +0 -1
- package/dist/resumable/stores/InMemoryResumableStreamStore.d.ts.map +1 -1
- package/dist/resumable/stores/InMemoryResumableStreamStore.js +2 -1
- package/dist/resumable/stores/InMemoryResumableStreamStore.js.map +1 -1
- package/dist/resumable/stores/ioredis.d.ts +0 -1
- package/dist/resumable/stores/ioredis.d.ts.map +1 -1
- package/dist/resumable/stores/redis-impl.d.ts +2 -2
- package/dist/resumable/stores/redis-impl.d.ts.map +1 -1
- package/dist/resumable/stores/redis.d.ts +0 -1
- package/dist/resumable/stores/redis.d.ts.map +1 -1
- package/dist/resumable/types.d.ts.map +1 -1
- package/dist/utils/AsyncIterableStream.d.ts.map +1 -1
- package/dist/utils/json/fix-json.d.ts.map +1 -1
- package/dist/utils/json/json-value.d.ts.map +1 -1
- package/dist/utils/json/parse-partial-json-object.d.ts +0 -1
- package/dist/utils/json/parse-partial-json-object.d.ts.map +1 -1
- package/dist/utils/promiseWithResolvers.d.ts.map +1 -1
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +2 -1
- package/package.json +5 -5
- package/src/core/AssistantStreamChunk.ts +2 -0
- package/src/core/accumulators/assistant-message-accumulator.test.ts +63 -0
- package/src/core/accumulators/assistant-message-accumulator.ts +15 -1
- package/src/core/object/ObjectStream.test.ts +20 -0
- package/src/core/object/ObjectStreamResponse.test.ts +32 -0
- package/src/core/object/ObjectStreamResponse.ts +6 -1
- package/src/core/serialization/assistant-transport/AssistantTransport.test.ts +41 -0
- package/src/core/serialization/assistant-transport/AssistantTransport.ts +6 -70
- package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +212 -4
- package/src/core/serialization/ui-message-stream/UIMessageStream.ts +62 -71
- package/src/core/tool/tool-types.ts +4 -0
- package/src/core/utils/stream/LineDecoderStream.test.ts +26 -2
- package/src/core/utils/stream/LineDecoderStream.ts +21 -11
- package/src/core/utils/stream/SSE.ts +6 -70
- package/src/core/utils/stream/SSEEventDecoder.test.ts +146 -0
- package/src/core/utils/stream/SSEEventDecoder.ts +109 -0
- package/src/core/utils/stream/SSEEventDecoderStream.test.ts +42 -0
- package/src/core/utils/stream/SSEEventDecoderStream.ts +34 -0
- package/src/utils.ts +4 -0
- package/dist/utils/json/is-json.d.ts +0 -9
- package/dist/utils/json/is-json.d.ts.map +0 -1
- package/dist/utils/json/is-json.js +0 -17
- package/dist/utils/json/is-json.js.map +0 -1
- 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
|
-
|
|
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 {
|
|
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
|
|
119
|
-
.pipeThrough(new SSEEventStream())
|
|
55
|
+
.pipeThrough(new SSEEventDecoderStream())
|
|
120
56
|
.pipeThrough(
|
|
121
|
-
new TransformStream<
|
|
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",
|
|
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","
|
|
335
|
-
'data: {"type":"text-delta","
|
|
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 {
|
|
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
|
|
275
|
-
.pipeThrough(new SSEEventStream())
|
|
211
|
+
.pipeThrough(new SSEEventDecoderStream())
|
|
276
212
|
.pipeThrough(
|
|
277
|
-
new TransformStream<
|
|
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
|
-
|
|
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) {
|
|
@@ -348,6 +348,8 @@ export type McpServerConfig =
|
|
|
348
348
|
url: string;
|
|
349
349
|
headers?: Record<string, string>;
|
|
350
350
|
redirect?: "follow" | "error";
|
|
351
|
+
/** Optional timeout in milliseconds for client creation and tool listing. */
|
|
352
|
+
connectionTimeout?: number | undefined;
|
|
351
353
|
}
|
|
352
354
|
| {
|
|
353
355
|
/** Start and connect to a local MCP server over stdio. */
|
|
@@ -356,6 +358,8 @@ export type McpServerConfig =
|
|
|
356
358
|
args?: readonly string[];
|
|
357
359
|
env?: Record<string, string>;
|
|
358
360
|
cwd?: string;
|
|
361
|
+
/** Optional timeout in milliseconds for client creation and tool listing. */
|
|
362
|
+
connectionTimeout?: number | undefined;
|
|
359
363
|
};
|
|
360
364
|
|
|
361
365
|
type McpTool = ToolBase<Record<string, unknown>, unknown> & {
|
|
@@ -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
|
|
69
|
-
const stream = createTextStream(["line1\
|
|
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
|
);
|