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