assistant-stream 0.3.26 → 0.3.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/core/AssistantStream.d.ts.map +1 -1
  2. package/dist/core/AssistantStreamChunk.d.ts +3 -3
  3. package/dist/core/accumulators/AssistantMessageStream.d.ts.map +1 -1
  4. package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
  5. package/dist/core/accumulators/assistant-message-accumulator.js +65 -28
  6. package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
  7. package/dist/core/converters/toGenericMessages.js.map +1 -1
  8. package/dist/core/gorp/GorpStreamAccumulator.d.ts +15 -0
  9. package/dist/core/gorp/GorpStreamAccumulator.d.ts.map +1 -0
  10. package/dist/core/{object/ObjectStreamAccumulator.js → gorp/GorpStreamAccumulator.js} +11 -9
  11. package/dist/core/gorp/GorpStreamAccumulator.js.map +1 -0
  12. package/dist/core/gorp/GorpStreamDeltaTracker.d.ts +17 -0
  13. package/dist/core/gorp/GorpStreamDeltaTracker.d.ts.map +1 -0
  14. package/dist/core/gorp/GorpStreamDeltaTracker.js +38 -0
  15. package/dist/core/gorp/GorpStreamDeltaTracker.js.map +1 -0
  16. package/dist/core/gorp/GorpStreamResponse.d.ts +16 -0
  17. package/dist/core/gorp/GorpStreamResponse.d.ts.map +1 -0
  18. package/dist/core/gorp/GorpStreamResponse.js +51 -0
  19. package/dist/core/gorp/GorpStreamResponse.js.map +1 -0
  20. package/dist/core/gorp/changeTree.d.ts +16 -0
  21. package/dist/core/gorp/changeTree.d.ts.map +1 -0
  22. package/dist/core/gorp/changeTree.js +77 -0
  23. package/dist/core/gorp/changeTree.js.map +1 -0
  24. package/dist/core/gorp/createGorpStream.d.ts +16 -0
  25. package/dist/core/gorp/createGorpStream.d.ts.map +1 -0
  26. package/dist/core/{object/createObjectStream.js → gorp/createGorpStream.js} +13 -8
  27. package/dist/core/gorp/createGorpStream.js.map +1 -0
  28. package/dist/core/{object → gorp}/types.d.ts +6 -5
  29. package/dist/core/gorp/types.d.ts.map +1 -0
  30. package/dist/core/modules/assistant-stream.d.ts +5 -4
  31. package/dist/core/modules/assistant-stream.d.ts.map +1 -1
  32. package/dist/core/modules/assistant-stream.js +10 -6
  33. package/dist/core/modules/assistant-stream.js.map +1 -1
  34. package/dist/core/modules/tool-call.js +2 -2
  35. package/dist/core/modules/tool-call.js.map +1 -1
  36. package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts.map +1 -1
  37. package/dist/core/serialization/assistant-transport/AssistantTransport.js +91 -1
  38. package/dist/core/serialization/assistant-transport/AssistantTransport.js.map +1 -1
  39. package/dist/core/serialization/data-stream/chunk-types.d.ts +2 -2
  40. package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
  41. package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
  42. package/dist/core/serialization/ui-message-stream/UIMessageStream.js +11 -1
  43. package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
  44. package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
  45. package/dist/core/tool/ToolCallReader.js.map +1 -1
  46. package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
  47. package/dist/core/tool/ToolExecutionStream.js +9 -1
  48. package/dist/core/tool/ToolExecutionStream.js.map +1 -1
  49. package/dist/core/tool/ToolResponse.d.ts.map +1 -1
  50. package/dist/core/tool/toolResultStream.d.ts.map +1 -1
  51. package/dist/core/tool/toolResultStream.js +7 -2
  52. package/dist/core/tool/toolResultStream.js.map +1 -1
  53. package/dist/core/utils/stream/AssistantTransformStream.d.ts.map +1 -1
  54. package/dist/core/utils/stream/PipeableTransformStream.d.ts.map +1 -1
  55. package/dist/core/utils/stream/SSEEventDecoder.d.ts.map +1 -1
  56. package/dist/core/utils/stream/merge.d.ts +2 -0
  57. package/dist/core/utils/stream/merge.d.ts.map +1 -1
  58. package/dist/core/utils/stream/merge.js +27 -8
  59. package/dist/core/utils/stream/merge.js.map +1 -1
  60. package/dist/core/utils/stream/path-utils.d.ts.map +1 -1
  61. package/dist/index.d.ts +25 -4
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +14 -3
  64. package/dist/index.js.map +1 -0
  65. package/dist/resumable/ResumableStreamContext.d.ts.map +1 -1
  66. package/dist/resumable/ResumableStreamContext.js +12 -5
  67. package/dist/resumable/ResumableStreamContext.js.map +1 -1
  68. package/dist/resumable/errors.d.ts.map +1 -1
  69. package/dist/resumable/stores/redis-impl.d.ts.map +1 -1
  70. package/dist/utils/AsyncIterableStream.d.ts.map +1 -1
  71. package/dist/utils/AsyncIterableStream.js +18 -3
  72. package/dist/utils/AsyncIterableStream.js.map +1 -1
  73. package/dist/utils/promiseWithResolvers.d.ts.map +1 -1
  74. package/package.json +2 -2
  75. package/src/core/AssistantStreamChunk.ts +3 -3
  76. package/src/core/accumulators/assistant-message-accumulator.test.ts +374 -1
  77. package/src/core/accumulators/assistant-message-accumulator.ts +75 -26
  78. package/src/core/{object/ObjectStream.test.ts → gorp/GorpStream.test.ts} +86 -25
  79. package/src/core/gorp/GorpStreamAccumulator.test.ts +28 -0
  80. package/src/core/{object/ObjectStreamAccumulator.ts → gorp/GorpStreamAccumulator.ts} +13 -15
  81. package/src/core/gorp/GorpStreamDeltaTracker.test.ts +182 -0
  82. package/src/core/gorp/GorpStreamDeltaTracker.ts +55 -0
  83. package/src/core/{object/ObjectStreamResponse.test.ts → gorp/GorpStreamResponse.test.ts} +6 -6
  84. package/src/core/gorp/GorpStreamResponse.ts +88 -0
  85. package/src/core/gorp/changeTree.test.ts +168 -0
  86. package/src/core/gorp/changeTree.ts +96 -0
  87. package/src/core/{object/createObjectStream.ts → gorp/createGorpStream.ts} +23 -17
  88. package/src/core/{object → gorp}/types.ts +5 -3
  89. package/src/core/modules/assistant-stream.test.ts +154 -2
  90. package/src/core/modules/assistant-stream.ts +16 -6
  91. package/src/core/modules/tool-call.test.ts +67 -0
  92. package/src/core/modules/tool-call.ts +3 -3
  93. package/src/core/serialization/assistant-transport/AssistantTransport.test.ts +174 -1
  94. package/src/core/serialization/assistant-transport/AssistantTransport.ts +77 -1
  95. package/src/core/serialization/data-stream/chunk-types.ts +2 -2
  96. package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +111 -0
  97. package/src/core/serialization/ui-message-stream/UIMessageStream.ts +20 -1
  98. package/src/core/tool/ToolExecutionStream.ts +10 -0
  99. package/src/core/tool/toolResultStream.test.ts +50 -0
  100. package/src/core/tool/toolResultStream.ts +7 -2
  101. package/src/core/utils/stream/merge.ts +31 -9
  102. package/src/gorp.test.ts +42 -0
  103. package/src/index.ts +21 -6
  104. package/src/resumable/ResumableStreamContext.test.ts +116 -1
  105. package/src/resumable/ResumableStreamContext.ts +32 -5
  106. package/src/resumable/createResumableAssistantStreamResponse.test.ts +1 -25
  107. package/src/utils/AsyncIterableStream.test.ts +61 -0
  108. package/src/utils/AsyncIterableStream.ts +18 -3
  109. package/dist/core/object/ObjectStreamAccumulator.d.ts +0 -15
  110. package/dist/core/object/ObjectStreamAccumulator.d.ts.map +0 -1
  111. package/dist/core/object/ObjectStreamAccumulator.js.map +0 -1
  112. package/dist/core/object/ObjectStreamResponse.d.ts +0 -16
  113. package/dist/core/object/ObjectStreamResponse.d.ts.map +0 -1
  114. package/dist/core/object/ObjectStreamResponse.js +0 -57
  115. package/dist/core/object/ObjectStreamResponse.js.map +0 -1
  116. package/dist/core/object/createObjectStream.d.ts +0 -16
  117. package/dist/core/object/createObjectStream.d.ts.map +0 -1
  118. package/dist/core/object/createObjectStream.js.map +0 -1
  119. package/dist/core/object/types.d.ts.map +0 -1
  120. package/src/core/object/ObjectStreamResponse.ts +0 -114
  121. /package/dist/core/{object → gorp}/types.js +0 -0
@@ -1,32 +1,35 @@
1
1
  import type { ReadonlyJSONValue } from "../../utils";
2
2
  import { withPromiseOrValue } from "../utils/withPromiseOrValue";
3
- import { ObjectStreamAccumulator } from "./ObjectStreamAccumulator";
4
- import type { ObjectStreamOperation, ObjectStreamChunk } from "./types";
3
+ import { GorpStreamAccumulator } from "./GorpStreamAccumulator";
4
+ import type { GorpStreamOperation, GorpStreamChunk } from "./types";
5
5
 
6
- type ObjectStreamController = {
6
+ type GorpStreamController = {
7
7
  readonly abortSignal: AbortSignal;
8
8
 
9
- enqueue(operations: readonly ObjectStreamOperation[]): void;
9
+ enqueue(operations: readonly GorpStreamOperation[]): void;
10
10
  };
11
11
 
12
- class ObjectStreamControllerImpl implements ObjectStreamController {
13
- private _controller: ReadableStreamDefaultController<ObjectStreamChunk>;
12
+ class GorpStreamControllerImpl implements GorpStreamController {
13
+ private _controller: ReadableStreamDefaultController<GorpStreamChunk>;
14
14
  private _abortController = new AbortController();
15
- private _accumulator: ObjectStreamAccumulator;
15
+ private _accumulator: GorpStreamAccumulator;
16
+ private _cancelled = false;
16
17
 
17
18
  get abortSignal() {
18
19
  return this._abortController.signal;
19
20
  }
20
21
 
21
22
  constructor(
22
- controller: ReadableStreamDefaultController<ObjectStreamChunk>,
23
+ controller: ReadableStreamDefaultController<GorpStreamChunk>,
23
24
  defaultValue: ReadonlyJSONValue,
24
25
  ) {
25
26
  this._controller = controller;
26
- this._accumulator = new ObjectStreamAccumulator(defaultValue);
27
+ this._accumulator = new GorpStreamAccumulator(defaultValue);
27
28
  }
28
29
 
29
- enqueue(operations: readonly ObjectStreamOperation[]) {
30
+ enqueue(operations: readonly GorpStreamOperation[]) {
31
+ if (this._cancelled) return;
32
+
30
33
  this._accumulator.append(operations);
31
34
 
32
35
  this._controller.enqueue({
@@ -36,23 +39,26 @@ class ObjectStreamControllerImpl implements ObjectStreamController {
36
39
  }
37
40
 
38
41
  __internalError(error: unknown) {
42
+ if (this._cancelled) return;
39
43
  this._controller.error(error);
40
44
  }
41
45
 
42
46
  __internalClose() {
47
+ if (this._cancelled) return;
43
48
  this._controller.close();
44
49
  }
45
50
 
46
51
  __internalCancel(reason?: unknown) {
52
+ this._cancelled = true;
47
53
  this._abortController.abort(reason);
48
54
  }
49
55
  }
50
56
 
51
57
  const getStreamControllerPair = (defaultValue: ReadonlyJSONValue) => {
52
- let controller!: ObjectStreamControllerImpl;
53
- const stream = new ReadableStream<ObjectStreamChunk>({
58
+ let controller!: GorpStreamControllerImpl;
59
+ const stream = new ReadableStream<GorpStreamChunk>({
54
60
  start(c) {
55
- controller = new ObjectStreamControllerImpl(c, defaultValue);
61
+ controller = new GorpStreamControllerImpl(c, defaultValue);
56
62
  },
57
63
  cancel(reason: unknown) {
58
64
  controller.__internalCancel(reason);
@@ -62,15 +68,15 @@ const getStreamControllerPair = (defaultValue: ReadonlyJSONValue) => {
62
68
  return [stream, controller] as const;
63
69
  };
64
70
 
65
- type CreateObjectStreamOptions = {
66
- execute: (controller: ObjectStreamController) => void | PromiseLike<void>;
71
+ type CreateGorpStreamOptions = {
72
+ execute: (controller: GorpStreamController) => void | PromiseLike<void>;
67
73
  defaultValue?: ReadonlyJSONValue;
68
74
  };
69
75
 
70
- export const createObjectStream = ({
76
+ export const createGorpStream = ({
71
77
  execute,
72
78
  defaultValue = {},
73
- }: CreateObjectStreamOptions) => {
79
+ }: CreateGorpStreamOptions) => {
74
80
  const [stream, controller] = getStreamControllerPair(defaultValue);
75
81
 
76
82
  withPromiseOrValue(
@@ -1,6 +1,6 @@
1
1
  import type { ReadonlyJSONValue } from "../../utils";
2
2
 
3
- export type ObjectStreamOperation =
3
+ export type AssistantTransportStateOperation =
4
4
  | {
5
5
  readonly type: "set";
6
6
  readonly path: readonly string[];
@@ -12,7 +12,9 @@ export type ObjectStreamOperation =
12
12
  readonly value: string;
13
13
  };
14
14
 
15
- export type ObjectStreamChunk = {
15
+ export type GorpStreamOperation = AssistantTransportStateOperation;
16
+
17
+ export type GorpStreamChunk = {
16
18
  readonly snapshot: ReadonlyJSONValue;
17
- readonly operations: readonly ObjectStreamOperation[];
19
+ readonly operations: readonly GorpStreamOperation[];
18
20
  };
@@ -1,6 +1,10 @@
1
- import { describe, it, expect } from "vitest";
2
- import { createAssistantStreamResponse } from "./assistant-stream";
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import {
3
+ createAssistantStream,
4
+ createAssistantStreamResponse,
5
+ } from "./assistant-stream";
3
6
  import { AssistantStream } from "../AssistantStream";
7
+ import type { AssistantStreamChunk } from "../AssistantStreamChunk";
4
8
  import { DataStreamDecoder } from "../serialization/data-stream/DataStream";
5
9
  import { AssistantMessageAccumulator } from "../accumulators/assistant-message-accumulator";
6
10
  import type { AssistantMessage } from "../utils/types";
@@ -21,6 +25,154 @@ const accumulate = async (response: Response): Promise<AssistantMessage> => {
21
25
  return last!;
22
26
  };
23
27
 
28
+ const collectChunks = async (
29
+ stream: AssistantStream,
30
+ ): Promise<AssistantStreamChunk[]> => {
31
+ const chunks: AssistantStreamChunk[] = [];
32
+ await stream.pipeTo(
33
+ new WritableStream({
34
+ write(chunk) {
35
+ chunks.push(chunk);
36
+ },
37
+ }),
38
+ );
39
+ return chunks;
40
+ };
41
+
42
+ const captureUnhandledRejections = async (
43
+ callback: () => Promise<void>,
44
+ ): Promise<unknown[]> => {
45
+ const reasons: unknown[] = [];
46
+ const listener = (reason: unknown) => reasons.push(reason);
47
+ process.on("unhandledRejection", listener);
48
+ try {
49
+ await callback();
50
+ await new Promise((resolve) => setTimeout(resolve, 0));
51
+ return reasons;
52
+ } finally {
53
+ process.off("unhandledRejection", listener);
54
+ }
55
+ };
56
+
57
+ describe("createAssistantStream task settlement", () => {
58
+ it("emits callback failures without leaking an unhandled rejection", async () => {
59
+ let chunks: AssistantStreamChunk[] = [];
60
+ const unhandledRejections = await captureUnhandledRejections(async () => {
61
+ chunks = await collectChunks(
62
+ createAssistantStream(async () => {
63
+ throw new Error("provider failed");
64
+ }),
65
+ );
66
+ });
67
+
68
+ expect(chunks).toEqual([
69
+ {
70
+ type: "error",
71
+ path: [],
72
+ error: "Error: provider failed",
73
+ },
74
+ ]);
75
+ expect(unhandledRejections).toEqual([]);
76
+ });
77
+
78
+ it("does not settle the stream again after cancellation", async () => {
79
+ let finishCallback!: () => void;
80
+ const callbackPending = new Promise<void>((resolve) => {
81
+ finishCallback = resolve;
82
+ });
83
+
84
+ const unhandledRejections = await captureUnhandledRejections(async () => {
85
+ const reader = createAssistantStream(() => callbackPending).getReader();
86
+ await reader.cancel("consumer stopped");
87
+ finishCallback();
88
+ await callbackPending;
89
+ });
90
+
91
+ expect(unhandledRejections).toEqual([]);
92
+ });
93
+
94
+ it("reports callback failures after the controller is explicitly closed", async () => {
95
+ const error = new Error("cleanup failed");
96
+ const consoleError = vi
97
+ .spyOn(console, "error")
98
+ .mockImplementation(() => {});
99
+
100
+ try {
101
+ const chunks = await collectChunks(
102
+ createAssistantStream((controller) => {
103
+ controller.close();
104
+ throw error;
105
+ }),
106
+ );
107
+
108
+ expect(chunks).toEqual([]);
109
+ expect(consoleError).toHaveBeenCalledOnce();
110
+ expect(consoleError).toHaveBeenCalledWith(error);
111
+ } finally {
112
+ consoleError.mockRestore();
113
+ }
114
+ });
115
+
116
+ it("does not report callback failures caused after cancellation", async () => {
117
+ let failCallback!: (error: Error) => void;
118
+ const callbackPending = new Promise<void>((_, reject) => {
119
+ failCallback = reject;
120
+ });
121
+ const consoleError = vi
122
+ .spyOn(console, "error")
123
+ .mockImplementation(() => {});
124
+
125
+ try {
126
+ const unhandledRejections = await captureUnhandledRejections(async () => {
127
+ const reader = createAssistantStream(() => callbackPending).getReader();
128
+ await reader.cancel("consumer stopped");
129
+ failCallback(new Error("provider stopped"));
130
+ await callbackPending.catch(() => undefined);
131
+ });
132
+
133
+ expect(unhandledRejections).toEqual([]);
134
+ expect(consoleError).not.toHaveBeenCalled();
135
+ } finally {
136
+ consoleError.mockRestore();
137
+ }
138
+ });
139
+
140
+ it("does not settle the outer stream again after a merged stream errors", async () => {
141
+ let finishCallback!: () => void;
142
+ const callbackPending = new Promise<void>((resolve) => {
143
+ finishCallback = resolve;
144
+ });
145
+ const streamError = new Error("merged stream failed");
146
+ const consoleError = vi
147
+ .spyOn(console, "error")
148
+ .mockImplementation(() => {});
149
+
150
+ try {
151
+ const unhandledRejections = await captureUnhandledRejections(async () => {
152
+ const stream = createAssistantStream(async (controller) => {
153
+ controller.merge(
154
+ new ReadableStream({
155
+ start(streamController) {
156
+ streamController.error(streamError);
157
+ },
158
+ }),
159
+ );
160
+ await callbackPending;
161
+ });
162
+
163
+ await expect(collectChunks(stream)).rejects.toBe(streamError);
164
+ finishCallback();
165
+ await callbackPending;
166
+ });
167
+
168
+ expect(unhandledRejections).toEqual([]);
169
+ expect(consoleError).toHaveBeenCalledWith(streamError);
170
+ } finally {
171
+ consoleError.mockRestore();
172
+ }
173
+ });
174
+ });
175
+
24
176
  describe("AssistantStreamController withParentId", () => {
25
177
  it("attaches parentId to text parts across a data-stream round trip", async () => {
26
178
  const response = createAssistantStreamResponse((controller) => {
@@ -115,7 +115,15 @@ class AssistantStreamControllerImpl implements AssistantStreamController {
115
115
  }
116
116
 
117
117
  get __internal_isClosed() {
118
- return this._state.merger.isSealed();
118
+ return (
119
+ this._state.merger.isSealed() ||
120
+ this._state.merger.isCancelled() ||
121
+ this._state.merger.isErrored()
122
+ );
123
+ }
124
+
125
+ get __internal_isCancelled() {
126
+ return this._state.merger.isCancelled();
119
127
  }
120
128
 
121
129
  __internal_getReadable() {
@@ -284,10 +292,11 @@ class AssistantStreamControllerImpl implements AssistantStreamController {
284
292
  * Creates an {@link AssistantStream} and writes to it with an
285
293
  * {@link AssistantStreamController}.
286
294
  *
287
- * The callback may write synchronously or asynchronously. If it throws, an
288
- * `error` chunk is emitted before the error is rethrown; when the callback
289
- * settles, the stream is closed automatically unless the controller was
290
- * already closed.
295
+ * The callback may write synchronously or asynchronously. If it throws while
296
+ * the stream is open, an `error` chunk is emitted. Failures after an explicit
297
+ * close are logged, while failures after consumer cancellation are discarded.
298
+ * When the callback settles, the stream is closed automatically unless the
299
+ * controller was already closed.
291
300
  */
292
301
  export function createAssistantStream(
293
302
  callback: (controller: AssistantStreamController) => PromiseLike<void> | void,
@@ -304,8 +313,9 @@ export function createAssistantStream(
304
313
  path: [],
305
314
  error: String(e),
306
315
  });
316
+ } else if (!controller.__internal_isCancelled) {
317
+ console.error(e);
307
318
  }
308
- throw e;
309
319
  } finally {
310
320
  if (!controller.__internal_isClosed) {
311
321
  controller.close();
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createAssistantStreamController } from "./assistant-stream";
3
+ import { ToolResponse } from "../tool/ToolResponse";
4
+ import { toolResultStream } from "../tool/toolResultStream";
5
+ import type { ToolCallReader } from "../tool/tool-types";
6
+ import type { AssistantStreamChunk } from "../AssistantStreamChunk";
7
+
8
+ type Reader = ToolCallReader<Record<string, unknown>, unknown>;
9
+
10
+ describe("ToolCallStreamController", () => {
11
+ it("delivers a backend response before an args parse failure", async () => {
12
+ const [stream, controller] = createAssistantStreamController();
13
+ let resolveToolReader!: (reader: Reader) => void;
14
+ const toolReaderPromise = new Promise<Reader>((resolve) => {
15
+ resolveToolReader = resolve;
16
+ });
17
+ const streamCall = vi.fn((reader: Reader) => {
18
+ resolveToolReader(reader);
19
+ });
20
+ const execute = vi.fn();
21
+ const output = stream.pipeThrough(
22
+ toolResultStream(
23
+ {
24
+ weatherSearch: {
25
+ parameters: { type: "object", properties: {} },
26
+ execute,
27
+ streamCall,
28
+ },
29
+ },
30
+ new AbortController().signal,
31
+ async () => undefined,
32
+ ),
33
+ );
34
+ const chunks: AssistantStreamChunk[] = [];
35
+ const drain = output.pipeTo(
36
+ new WritableStream({
37
+ write(chunk) {
38
+ chunks.push(chunk);
39
+ },
40
+ }),
41
+ );
42
+
43
+ const toolCall = controller.addToolCallPart({
44
+ toolCallId: "tool-1",
45
+ toolName: "weatherSearch",
46
+ });
47
+ toolCall.argsText.append('{"query":"London","longitude":0');
48
+ const reader = await toolReaderPromise;
49
+ expect(await reader.args.get("query")).toBe("London");
50
+
51
+ toolCall.setResponse(new ToolResponse({ result: { source: "backend" } }));
52
+ toolCall.close();
53
+ controller.close();
54
+
55
+ const response = await reader.response.get();
56
+ expect(response.result).toEqual({ source: "backend" });
57
+ expect(response.isError).toBe(false);
58
+ expect(execute).not.toHaveBeenCalled();
59
+ await drain;
60
+ const results = chunks.filter((chunk) => chunk.type === "result");
61
+ expect(results).toHaveLength(1);
62
+ expect(results[0]).toMatchObject({
63
+ result: { source: "backend" },
64
+ isError: false,
65
+ });
66
+ });
67
+ });
@@ -65,9 +65,6 @@ class ToolCallStreamControllerImpl implements ToolCallStreamController {
65
65
  private _argsTextController!: TextStreamController;
66
66
 
67
67
  async setResponse(response: ToolResponseLike<ReadonlyJSONValue>) {
68
- this._argsTextController.close();
69
- await Promise.resolve(); // flush microtask queue
70
- // TODO switch argsTextController to be something that doesn'#t require this
71
68
  this._controller.enqueue({
72
69
  type: "result",
73
70
  path: [],
@@ -83,6 +80,9 @@ class ToolCallStreamControllerImpl implements ToolCallStreamController {
83
80
  ? { messages: response.messages }
84
81
  : {}),
85
82
  });
83
+ this._argsTextController.close();
84
+ await Promise.resolve(); // flush microtask queue
85
+ // TODO switch argsTextController to be something that doesn'#t require this
86
86
  }
87
87
 
88
88
  async close() {
@@ -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
  {