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.
Files changed (145) 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/AssistantMessageStream.js +0 -1
  5. package/dist/core/accumulators/AssistantMessageStream.js.map +1 -1
  6. package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
  7. package/dist/core/accumulators/assistant-message-accumulator.js +65 -28
  8. package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
  9. package/dist/core/converters/toGenericMessages.js.map +1 -1
  10. package/dist/core/gorp/GorpStreamAccumulator.d.ts +15 -0
  11. package/dist/core/gorp/GorpStreamAccumulator.d.ts.map +1 -0
  12. package/dist/core/{object/ObjectStreamAccumulator.js → gorp/GorpStreamAccumulator.js} +11 -9
  13. package/dist/core/gorp/GorpStreamAccumulator.js.map +1 -0
  14. package/dist/core/gorp/GorpStreamDeltaTracker.d.ts +17 -0
  15. package/dist/core/gorp/GorpStreamDeltaTracker.d.ts.map +1 -0
  16. package/dist/core/gorp/GorpStreamDeltaTracker.js +38 -0
  17. package/dist/core/gorp/GorpStreamDeltaTracker.js.map +1 -0
  18. package/dist/core/gorp/GorpStreamResponse.d.ts +16 -0
  19. package/dist/core/gorp/GorpStreamResponse.d.ts.map +1 -0
  20. package/dist/core/gorp/GorpStreamResponse.js +51 -0
  21. package/dist/core/gorp/GorpStreamResponse.js.map +1 -0
  22. package/dist/core/gorp/changeTree.d.ts +16 -0
  23. package/dist/core/gorp/changeTree.d.ts.map +1 -0
  24. package/dist/core/gorp/changeTree.js +77 -0
  25. package/dist/core/gorp/changeTree.js.map +1 -0
  26. package/dist/core/gorp/createGorpStream.d.ts +16 -0
  27. package/dist/core/gorp/createGorpStream.d.ts.map +1 -0
  28. package/dist/core/{object/createObjectStream.js → gorp/createGorpStream.js} +13 -8
  29. package/dist/core/gorp/createGorpStream.js.map +1 -0
  30. package/dist/core/{object → gorp}/types.d.ts +6 -5
  31. package/dist/core/gorp/types.d.ts.map +1 -0
  32. package/dist/core/modules/assistant-stream.d.ts +5 -4
  33. package/dist/core/modules/assistant-stream.d.ts.map +1 -1
  34. package/dist/core/modules/assistant-stream.js +10 -6
  35. package/dist/core/modules/assistant-stream.js.map +1 -1
  36. package/dist/core/modules/tool-call.d.ts.map +1 -1
  37. package/dist/core/modules/tool-call.js +3 -3
  38. package/dist/core/modules/tool-call.js.map +1 -1
  39. package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts.map +1 -1
  40. package/dist/core/serialization/assistant-transport/AssistantTransport.js +91 -1
  41. package/dist/core/serialization/assistant-transport/AssistantTransport.js.map +1 -1
  42. package/dist/core/serialization/data-stream/DataStream.js +48 -48
  43. package/dist/core/serialization/data-stream/DataStream.js.map +1 -1
  44. package/dist/core/serialization/data-stream/chunk-types.d.ts +25 -24
  45. package/dist/core/serialization/data-stream/chunk-types.d.ts.map +1 -1
  46. package/dist/core/serialization/data-stream/chunk-types.js +22 -23
  47. package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
  48. package/dist/core/serialization/data-stream/serialization.d.ts.map +1 -1
  49. package/dist/core/serialization/data-stream/serialization.js +14 -2
  50. package/dist/core/serialization/data-stream/serialization.js.map +1 -1
  51. package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
  52. package/dist/core/serialization/ui-message-stream/UIMessageStream.js +12 -1
  53. package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
  54. package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
  55. package/dist/core/tool/ToolCallReader.js.map +1 -1
  56. package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
  57. package/dist/core/tool/ToolExecutionStream.js +9 -1
  58. package/dist/core/tool/ToolExecutionStream.js.map +1 -1
  59. package/dist/core/tool/ToolResponse.d.ts.map +1 -1
  60. package/dist/core/tool/tool-types.d.ts +6 -0
  61. package/dist/core/tool/tool-types.d.ts.map +1 -1
  62. package/dist/core/tool/toolResultStream.d.ts.map +1 -1
  63. package/dist/core/tool/toolResultStream.js +7 -2
  64. package/dist/core/tool/toolResultStream.js.map +1 -1
  65. package/dist/core/utils/stream/AssistantTransformStream.d.ts.map +1 -1
  66. package/dist/core/utils/stream/PipeableTransformStream.d.ts.map +1 -1
  67. package/dist/core/utils/stream/SSE.d.ts.map +1 -1
  68. package/dist/core/utils/stream/SSE.js +11 -2
  69. package/dist/core/utils/stream/SSE.js.map +1 -1
  70. package/dist/core/utils/stream/SSEEventDecoder.d.ts.map +1 -1
  71. package/dist/core/utils/stream/merge.d.ts +2 -0
  72. package/dist/core/utils/stream/merge.d.ts.map +1 -1
  73. package/dist/core/utils/stream/merge.js +27 -8
  74. package/dist/core/utils/stream/merge.js.map +1 -1
  75. package/dist/core/utils/stream/path-utils.d.ts.map +1 -1
  76. package/dist/index.d.ts +25 -4
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +14 -3
  79. package/dist/index.js.map +1 -0
  80. package/dist/resumable/ResumableStreamContext.d.ts.map +1 -1
  81. package/dist/resumable/ResumableStreamContext.js +12 -5
  82. package/dist/resumable/ResumableStreamContext.js.map +1 -1
  83. package/dist/resumable/errors.d.ts.map +1 -1
  84. package/dist/resumable/stores/redis-impl.d.ts.map +1 -1
  85. package/dist/utils/AsyncIterableStream.d.ts.map +1 -1
  86. package/dist/utils/AsyncIterableStream.js +18 -3
  87. package/dist/utils/AsyncIterableStream.js.map +1 -1
  88. package/dist/utils/promiseWithResolvers.d.ts.map +1 -1
  89. package/package.json +2 -2
  90. package/src/core/AssistantStreamChunk.ts +3 -3
  91. package/src/core/accumulators/AssistantMessageStream.ts +3 -1
  92. package/src/core/accumulators/assistant-message-accumulator.test.ts +374 -1
  93. package/src/core/accumulators/assistant-message-accumulator.ts +75 -26
  94. package/src/core/converters/toGenericMessages.test.ts +3 -39
  95. package/src/core/{object/ObjectStream.test.ts → gorp/GorpStream.test.ts} +86 -25
  96. package/src/core/gorp/GorpStreamAccumulator.test.ts +28 -0
  97. package/src/core/{object/ObjectStreamAccumulator.ts → gorp/GorpStreamAccumulator.ts} +13 -15
  98. package/src/core/gorp/GorpStreamDeltaTracker.test.ts +212 -0
  99. package/src/core/gorp/GorpStreamDeltaTracker.ts +55 -0
  100. package/src/core/{object/ObjectStreamResponse.test.ts → gorp/GorpStreamResponse.test.ts} +6 -6
  101. package/src/core/gorp/GorpStreamResponse.ts +88 -0
  102. package/src/core/gorp/changeTree.ts +96 -0
  103. package/src/core/{object/createObjectStream.ts → gorp/createGorpStream.ts} +23 -17
  104. package/src/core/{object → gorp}/types.ts +5 -3
  105. package/src/core/modules/assistant-stream.test.ts +154 -2
  106. package/src/core/modules/assistant-stream.ts +16 -6
  107. package/src/core/modules/tool-call.test.ts +67 -0
  108. package/src/core/modules/tool-call.ts +7 -4
  109. package/src/core/serialization/assistant-transport/AssistantTransport.test.ts +174 -1
  110. package/src/core/serialization/assistant-transport/AssistantTransport.ts +77 -1
  111. package/src/core/serialization/data-stream/DataStream.ts +1 -1
  112. package/src/core/serialization/data-stream/chunk-types.ts +26 -24
  113. package/src/core/serialization/data-stream/serialization.test.ts +133 -0
  114. package/src/core/serialization/data-stream/serialization.ts +20 -2
  115. package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +135 -0
  116. package/src/core/serialization/ui-message-stream/UIMessageStream.ts +21 -1
  117. package/src/core/tool/ToolCallReader.ts +5 -1
  118. package/src/core/tool/ToolExecutionStream.ts +10 -0
  119. package/src/core/tool/tool-types.ts +7 -0
  120. package/src/core/tool/toolResultStream.test.ts +50 -0
  121. package/src/core/tool/toolResultStream.ts +7 -2
  122. package/src/core/utils/stream/SSE.test.ts +97 -0
  123. package/src/core/utils/stream/SSE.ts +13 -2
  124. package/src/core/utils/stream/merge.ts +31 -9
  125. package/src/gorp.test.ts +32 -0
  126. package/src/index.ts +21 -6
  127. package/src/resumable/ResumableStreamContext.test.ts +116 -1
  128. package/src/resumable/ResumableStreamContext.ts +32 -5
  129. package/src/resumable/createResumableAssistantStreamResponse.test.ts +1 -25
  130. package/src/utils/AsyncIterableStream.test.ts +61 -0
  131. package/src/utils/AsyncIterableStream.ts +18 -3
  132. package/dist/core/object/ObjectStreamAccumulator.d.ts +0 -15
  133. package/dist/core/object/ObjectStreamAccumulator.d.ts.map +0 -1
  134. package/dist/core/object/ObjectStreamAccumulator.js.map +0 -1
  135. package/dist/core/object/ObjectStreamResponse.d.ts +0 -16
  136. package/dist/core/object/ObjectStreamResponse.d.ts.map +0 -1
  137. package/dist/core/object/ObjectStreamResponse.js +0 -57
  138. package/dist/core/object/ObjectStreamResponse.js.map +0 -1
  139. package/dist/core/object/createObjectStream.d.ts +0 -16
  140. package/dist/core/object/createObjectStream.d.ts.map +0 -1
  141. package/dist/core/object/createObjectStream.js.map +0 -1
  142. package/dist/core/object/types.d.ts.map +0 -1
  143. package/src/core/accumulators/TimingTracker.test.ts +0 -100
  144. package/src/core/object/ObjectStreamResponse.ts +0 -114
  145. /package/dist/core/{object → gorp}/types.js +0 -0
@@ -1,3 +1,4 @@
1
+ import sjson from "secure-json-parse";
1
2
  import type { AssistantStreamChunk } from "../../AssistantStreamChunk";
2
3
  import type { ToolCallStreamController } from "../../modules/tool-call";
3
4
  import type { TextStreamController } from "../../modules/text";
@@ -149,6 +150,9 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
149
150
  `Encountered tool result with unknown id: ${chunk.toolCallId}`,
150
151
  );
151
152
  }
153
+ if (toolCallController.argsText === activeToolCallArgsText) {
154
+ activeToolCallArgsText = undefined;
155
+ }
152
156
  toolCallController.setResponse({
153
157
  result: chunk.result,
154
158
  isError: chunk.isError ?? false,
@@ -222,7 +226,23 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
222
226
  return;
223
227
  }
224
228
 
225
- const chunk = JSON.parse(event.data);
229
+ let chunk;
230
+ try {
231
+ chunk = sjson.parse(event.data);
232
+ } catch {
233
+ chunk = undefined;
234
+ }
235
+ if (
236
+ typeof chunk !== "object" ||
237
+ chunk === null ||
238
+ Array.isArray(chunk) ||
239
+ typeof chunk.type !== "string"
240
+ ) {
241
+ console.warn(
242
+ `Dropped invalid UIMessageStream chunk: ${event.data.slice(0, 200)}`,
243
+ );
244
+ return;
245
+ }
226
246
  if (
227
247
  chunk.type === "text-delta" &&
228
248
  chunk.textDelta === undefined
@@ -441,7 +441,11 @@ export class ToolCallArgsReaderImpl<
441
441
  export class ToolCallResponseReaderImpl<
442
442
  TResult extends ReadonlyJSONValue,
443
443
  > implements ToolCallResponseReader<TResult> {
444
- constructor(private readonly promise: Promise<ToolResponse<TResult>>) {}
444
+ private readonly promise: Promise<ToolResponse<TResult>>;
445
+
446
+ constructor(promise: Promise<ToolResponse<TResult>>) {
447
+ this.promise = promise;
448
+ }
445
449
 
446
450
  public get() {
447
451
  return this.promise;
@@ -51,6 +51,7 @@ export class ToolExecutionStream extends PipeableTransformStream<
51
51
  string,
52
52
  ToolCallReaderImpl<ReadonlyJSONObject, ReadonlyJSONValue>
53
53
  >();
54
+ const toolCallIdsWithBackendResult = new Set<string>();
54
55
 
55
56
  super((readable) => {
56
57
  const transform = new TransformStream<
@@ -109,6 +110,7 @@ export class ToolExecutionStream extends PipeableTransformStream<
109
110
  modelContent: chunk.modelContent,
110
111
  }),
111
112
  );
113
+ toolCallIdsWithBackendResult.add(toolCallId);
112
114
  break;
113
115
  }
114
116
  case "tool-call-args-text-finish": {
@@ -123,6 +125,11 @@ export class ToolExecutionStream extends PipeableTransformStream<
123
125
  // resolve. Awaited so the close settles before the writer is reused.
124
126
  await streamController.finishArgsText();
125
127
 
128
+ // A backend result is authoritative. Closing the args stream still
129
+ // emits this finish chunk, but must not parse stale/incomplete args,
130
+ // execute the frontend tool, or enqueue a second result.
131
+ if (toolCallIdsWithBackendResult.has(toolCallId)) break;
132
+
126
133
  let isExecuting = false;
127
134
  const promise = withPromiseOrValue(
128
135
  () => {
@@ -206,10 +213,13 @@ export class ToolExecutionStream extends PipeableTransformStream<
206
213
  toolCallPromise.then(() => {
207
214
  toolCallPromises.delete(toolCallId);
208
215
  toolCallControllers.delete(toolCallId);
216
+ toolCallIdsWithBackendResult.delete(toolCallId);
209
217
 
210
218
  controller.enqueue(chunk);
211
219
  });
212
220
  } else {
221
+ toolCallControllers.delete(toolCallId);
222
+ toolCallIdsWithBackendResult.delete(toolCallId);
213
223
  controller.enqueue(chunk);
214
224
  }
215
225
  }
@@ -226,6 +226,13 @@ type ToolBase<
226
226
  unstable_backendDefault?: {
227
227
  parameters?: boolean;
228
228
  };
229
+
230
+ /**
231
+ * Replaces an already-registered same-priority tool with the same name
232
+ * instead of throwing. Discouraged escape hatch — overwriting a same-priority
233
+ * tool is usually a design smell; prefer distinct names or priorities.
234
+ */
235
+ overwrite?: boolean;
229
236
  };
230
237
 
231
238
  type BackendTool<
@@ -17,6 +17,56 @@ const createDelayedTool = (delay: number, result?: string): Tool => ({
17
17
  });
18
18
 
19
19
  describe("unstable_runPendingTools", () => {
20
+ it("removes the abort listener after tool execution settles", async () => {
21
+ const abortController = new AbortController();
22
+ const addEventListener = vi.spyOn(
23
+ abortController.signal,
24
+ "addEventListener",
25
+ );
26
+ const removeEventListener = vi.spyOn(
27
+ abortController.signal,
28
+ "removeEventListener",
29
+ );
30
+ const message: AssistantMessage = {
31
+ role: "assistant",
32
+ status: { type: "requires-action", reason: "tool-calls" },
33
+ parts: [
34
+ {
35
+ type: "tool-call",
36
+ toolCallId: "1",
37
+ toolName: "tool",
38
+ args: {},
39
+ } as ToolCallPart,
40
+ ],
41
+ content: [],
42
+ metadata: {
43
+ unstable_state: {},
44
+ unstable_data: [],
45
+ unstable_annotations: [],
46
+ steps: [],
47
+ custom: {},
48
+ },
49
+ };
50
+
51
+ await unstable_runPendingTools(
52
+ message,
53
+ {
54
+ tool: {
55
+ parameters: { type: "object", properties: {} },
56
+ execute: async () => "done",
57
+ },
58
+ },
59
+ abortController.signal,
60
+ async () => {},
61
+ );
62
+
63
+ expect(addEventListener).toHaveBeenCalledOnce();
64
+ expect(removeEventListener).toHaveBeenCalledWith(
65
+ "abort",
66
+ addEventListener.mock.calls[0]![1],
67
+ );
68
+ });
69
+
20
70
  describe("parallel execution", () => {
21
71
  it("should run tool calls in parallel", async () => {
22
72
  const tool1 = createDelayedTool(100, "Tool 1");
@@ -59,9 +59,10 @@ function getToolResponse(
59
59
 
60
60
  // Create abort promise that resolves after 2 microtasks
61
61
  // This gives tools that handle abort a chance to win the race
62
+ let onAbort!: () => void;
62
63
  const abortPromise = new Promise<ToolResponse<ReadonlyJSONValue>>(
63
64
  (resolve) => {
64
- const onAbort = () => {
65
+ onAbort = () => {
65
66
  queueMicrotask(() => {
66
67
  queueMicrotask(() => {
67
68
  resolve(
@@ -116,7 +117,11 @@ function getToolResponse(
116
117
  return response;
117
118
  })();
118
119
 
119
- return Promise.race([executePromise, abortPromise]);
120
+ try {
121
+ return await Promise.race([executePromise, abortPromise]);
122
+ } finally {
123
+ abortSignal.removeEventListener("abort", onAbort);
124
+ }
120
125
  };
121
126
 
122
127
  return getResult(tool.execute);
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { SSEDecoder, SSEEncoder } from "./SSE";
3
+
4
+ async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> {
5
+ const reader = stream.getReader();
6
+ const chunks: T[] = [];
7
+ try {
8
+ while (true) {
9
+ const { done, value } = await reader.read();
10
+ if (done) break;
11
+ chunks.push(value);
12
+ }
13
+ } finally {
14
+ reader.releaseLock();
15
+ }
16
+ return chunks;
17
+ }
18
+
19
+ function createSSEStream(messages: string[]): ReadableStream<Uint8Array> {
20
+ const encoder = new TextEncoder();
21
+ const sseText = messages.map((m) => `data: ${m}\n\n`).join("");
22
+ return new ReadableStream<Uint8Array>({
23
+ start(controller) {
24
+ controller.enqueue(encoder.encode(sseText));
25
+ controller.close();
26
+ },
27
+ });
28
+ }
29
+
30
+ describe("SSEDecoder", () => {
31
+ it("decodes message events as JSON", async () => {
32
+ const chunks = await collectChunks(
33
+ createSSEStream([
34
+ JSON.stringify({ hello: "world" }),
35
+ JSON.stringify([1, 2, 3]),
36
+ ]).pipeThrough(new SSEDecoder()),
37
+ );
38
+ expect(chunks).toEqual([{ hello: "world" }, [1, 2, 3]]);
39
+ });
40
+
41
+ it("round-trips values through SSEEncoder and SSEDecoder", async () => {
42
+ type V = { hello: string } | number[];
43
+ const values: V[] = [{ hello: "world" }, [1, 2, 3]];
44
+ const source = new ReadableStream<V>({
45
+ start(controller) {
46
+ for (const v of values) controller.enqueue(v);
47
+ controller.close();
48
+ },
49
+ });
50
+ const decoded = await collectChunks(
51
+ source.pipeThrough(new SSEEncoder<V>()).pipeThrough(new SSEDecoder<V>()),
52
+ );
53
+ expect(decoded).toEqual(values);
54
+ });
55
+
56
+ it("drops frames carrying prototype-pollution keys", async () => {
57
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
58
+ const chunks = await collectChunks(
59
+ createSSEStream([
60
+ '{"__proto__":{"polluted":true},"ok":1}',
61
+ '{"constructor":{"prototype":{"polluted":true}},"ok":2}',
62
+ JSON.stringify({ ok: 3 }),
63
+ ]).pipeThrough(new SSEDecoder()),
64
+ );
65
+ expect(warn).toHaveBeenCalledTimes(2);
66
+ expect(chunks).toEqual([{ ok: 3 }]);
67
+ warn.mockRestore();
68
+ });
69
+
70
+ it("drops frames that are not valid JSON", async () => {
71
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
72
+ const invalid = ["{not json", "not json"];
73
+ const chunks = await collectChunks(
74
+ createSSEStream([...invalid, JSON.stringify({ ok: 1 })]).pipeThrough(
75
+ new SSEDecoder(),
76
+ ),
77
+ );
78
+ expect(warn.mock.calls.map((c) => c[0])).toEqual(
79
+ invalid.map((m) => `Dropped invalid SSE message: ${m}`),
80
+ );
81
+ expect(chunks).toEqual([{ ok: 1 }]);
82
+ warn.mockRestore();
83
+ });
84
+
85
+ it("errors the stream on an unknown event type", async () => {
86
+ const encoder = new TextEncoder();
87
+ const stream = new ReadableStream<Uint8Array>({
88
+ start(controller) {
89
+ controller.enqueue(encoder.encode("event: custom\ndata: x\n\n"));
90
+ controller.close();
91
+ },
92
+ });
93
+ await expect(
94
+ collectChunks(stream.pipeThrough(new SSEDecoder())),
95
+ ).rejects.toThrow("Unknown SSE event type: custom");
96
+ });
97
+ });
@@ -1,3 +1,4 @@
1
+ import sjson from "secure-json-parse";
1
2
  import { PipeableTransformStream } from "./PipeableTransformStream";
2
3
  import {
3
4
  SSEEventDecoderStream,
@@ -44,9 +45,19 @@ export class SSEDecoder<T> extends PipeableTransformStream<
44
45
  new TransformStream<PipelineSSEEvent, T>({
45
46
  transform(event, controller) {
46
47
  switch (event.event) {
47
- case "message":
48
- controller.enqueue(JSON.parse(event.data));
48
+ case "message": {
49
+ let value;
50
+ try {
51
+ value = sjson.parse(event.data);
52
+ } catch {
53
+ console.warn(
54
+ `Dropped invalid SSE message: ${event.data.slice(0, 200)}`,
55
+ );
56
+ break;
57
+ }
58
+ controller.enqueue(value);
49
59
  break;
60
+ }
50
61
  default:
51
62
  throw new Error(`Unknown SSE event type: ${event.event}`);
52
63
  }
@@ -9,9 +9,18 @@ type MergeStreamItem = {
9
9
  export const createMergeStream = () => {
10
10
  const list: MergeStreamItem[] = [];
11
11
  let sealed = false;
12
+ let cancelled = false;
13
+ let errored = false;
12
14
  let controller: ReadableStreamDefaultController<AssistantStreamChunk>;
13
15
  let currentPull: ReturnType<typeof promiseWithResolvers<void>> | undefined;
14
16
 
17
+ const cancelAllReaders = () => {
18
+ list.forEach((item) => {
19
+ void item.reader.cancel().catch(() => undefined);
20
+ });
21
+ list.length = 0;
22
+ };
23
+
15
24
  const handlePull = (item: MergeStreamItem) => {
16
25
  if (!item.promise) {
17
26
  // TODO for most streams, we can directly pipeTo to avoid the microTask queue
@@ -23,6 +32,8 @@ export const createMergeStream = () => {
23
32
  .read()
24
33
  .then(({ done, value }) => {
25
34
  item.promise = undefined;
35
+ if (cancelled || errored) return;
36
+
26
37
  if (done) {
27
38
  list.splice(list.indexOf(item), 1);
28
39
  if (sealed && list.length === 0) {
@@ -36,12 +47,11 @@ export const createMergeStream = () => {
36
47
  currentPull = undefined;
37
48
  })
38
49
  .catch((e) => {
39
- console.error(e);
50
+ if (cancelled || errored) return;
40
51
 
41
- list.forEach((item) => {
42
- item.reader.cancel();
43
- });
44
- list.length = 0;
52
+ errored = true;
53
+ console.error(e);
54
+ cancelAllReaders();
45
55
 
46
56
  controller.error(e);
47
57
 
@@ -64,10 +74,10 @@ export const createMergeStream = () => {
64
74
  return currentPull.promise;
65
75
  },
66
76
  cancel() {
67
- list.forEach((item) => {
68
- item.reader.cancel();
69
- });
70
- list.length = 0;
77
+ cancelled = true;
78
+ cancelAllReaders();
79
+ currentPull?.resolve();
80
+ currentPull = undefined;
71
81
  },
72
82
  });
73
83
 
@@ -76,11 +86,23 @@ export const createMergeStream = () => {
76
86
  isSealed() {
77
87
  return sealed;
78
88
  },
89
+ isCancelled() {
90
+ return cancelled;
91
+ },
92
+ isErrored() {
93
+ return errored;
94
+ },
79
95
  seal() {
96
+ if (cancelled || errored) return;
80
97
  sealed = true;
81
98
  if (list.length === 0) controller.close();
82
99
  },
83
100
  addStream(stream: ReadableStream<AssistantStreamChunk>) {
101
+ if (cancelled || errored) {
102
+ void stream.cancel().catch(() => undefined);
103
+ return;
104
+ }
105
+
84
106
  if (sealed)
85
107
  throw new Error(
86
108
  "Cannot add streams after the run callback has settled.",
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as entry from "./index";
3
+
4
+ describe("assistant-transport state exports", () => {
5
+ it("streams operations end to end through the SSE wire", async () => {
6
+ const stream = entry.createObjectStream({
7
+ execute: (controller) => {
8
+ controller.enqueue([
9
+ { type: "set", path: ["message"], value: "Hello" },
10
+ ]);
11
+ controller.enqueue([
12
+ { type: "append-text", path: ["message"], value: " World" },
13
+ ]);
14
+ },
15
+ });
16
+
17
+ const response = new entry.ObjectStreamResponse(stream);
18
+ const decoded = entry.fromObjectStreamResponse(response);
19
+
20
+ const tracker = new entry.AssistantTransportDeltaTracker();
21
+ const reader = decoded.getReader();
22
+ while (true) {
23
+ const { done, value } = await reader.read();
24
+ if (done) break;
25
+ tracker.append(value.operations);
26
+ expect(tracker.state).toEqual(value.snapshot);
27
+ }
28
+
29
+ expect(tracker.state).toEqual({ message: "Hello World" });
30
+ expect(tracker.isChangedAt(["message"])).toBe(true);
31
+ });
32
+ });
package/src/index.ts CHANGED
@@ -62,12 +62,27 @@ export {
62
62
  export type { TextStreamController } from "./core/modules/text";
63
63
  export type { ToolCallStreamController } from "./core/modules/tool-call";
64
64
 
65
- export { createObjectStream } from "./core/object/createObjectStream";
66
- export {
67
- ObjectStreamResponse,
68
- fromObjectStreamResponse,
69
- } from "./core/object/ObjectStreamResponse";
70
- export type { ObjectStreamChunk } from "./core/object/types";
65
+ import { createGorpStream } from "./core/gorp/createGorpStream";
66
+ import {
67
+ GorpStreamResponse,
68
+ fromGorpStreamResponse,
69
+ } from "./core/gorp/GorpStreamResponse";
70
+ import type { GorpStreamChunk } from "./core/gorp/types";
71
+ import { GorpStreamDeltaTracker } from "./core/gorp/GorpStreamDeltaTracker";
72
+
73
+ export { GorpStreamDeltaTracker as AssistantTransportDeltaTracker };
74
+ export type { AssistantTransportStateOperation } from "./core/gorp/types";
75
+
76
+ /** @deprecated Use the assistant-transport surface instead. */
77
+ export const createObjectStream = createGorpStream;
78
+ /** @deprecated Use the assistant-transport surface instead. */
79
+ export const ObjectStreamResponse = GorpStreamResponse;
80
+ /** @deprecated Use the assistant-transport surface instead. */
81
+ export type ObjectStreamResponse = GorpStreamResponse;
82
+ /** @deprecated Use the assistant-transport surface instead. */
83
+ export const fromObjectStreamResponse = fromGorpStreamResponse;
84
+ /** @deprecated Use the assistant-transport surface instead. */
85
+ export type ObjectStreamChunk = GorpStreamChunk;
71
86
 
72
87
  export {
73
88
  toGenericMessages,
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createResumableStreamContext } from "./ResumableStreamContext";
3
3
  import { ResumableStreamError } from "./errors";
4
4
  import { createInMemoryResumableStreamStore } from "./stores/InMemoryResumableStreamStore";
@@ -8,6 +8,10 @@ const dec = new TextDecoder();
8
8
 
9
9
  const bytes = (s: string): Uint8Array => enc.encode(s);
10
10
 
11
+ afterEach(() => {
12
+ vi.restoreAllMocks();
13
+ });
14
+
11
15
  async function collect(stream: ReadableStream<Uint8Array>): Promise<string> {
12
16
  const reader = stream.getReader();
13
17
  let out = "";
@@ -271,4 +275,115 @@ describe("createResumableStreamContext", () => {
271
275
  expect(errors[0]!.id).toBe("a");
272
276
  expect((errors[0]!.error as Error).message).toBe("boom");
273
277
  });
278
+
279
+ it("continues acquisition when onAcquire throws", async () => {
280
+ const consoleError = vi
281
+ .spyOn(console, "error")
282
+ .mockImplementation(() => {});
283
+ const hookError = new Error("acquire observer failed");
284
+ const store = createInMemoryResumableStreamStore();
285
+ const ctx = createResumableStreamContext({
286
+ store,
287
+ onAcquire: () => {
288
+ throw hookError;
289
+ },
290
+ });
291
+
292
+ const stream = await ctx.run("a", () => makeStringStream(["hello"]));
293
+
294
+ expect(await collect(stream)).toBe("hello");
295
+ expect(await ctx.status("a")).toBe("done");
296
+ expect(consoleError).toHaveBeenCalledWith(
297
+ "resumable stream onAcquire hook failed:",
298
+ hookError,
299
+ );
300
+ });
301
+
302
+ it("continues streaming when onAppend throws", async () => {
303
+ vi.spyOn(console, "error").mockImplementation(() => {});
304
+ const store = createInMemoryResumableStreamStore();
305
+ const ctx = createResumableStreamContext({
306
+ store,
307
+ onAppend: () => {
308
+ throw new Error("append observer failed");
309
+ },
310
+ });
311
+
312
+ const stream = await ctx.run("a", () =>
313
+ makeStringStream(["hello ", "world"]),
314
+ );
315
+
316
+ expect(await collect(stream)).toBe("hello world");
317
+ expect(await ctx.status("a")).toBe("done");
318
+ });
319
+
320
+ it("continues streaming when async onAppend rejects", async () => {
321
+ const consoleError = vi
322
+ .spyOn(console, "error")
323
+ .mockImplementation(() => {});
324
+ const hookError = new Error("async append observer failed");
325
+ const store = createInMemoryResumableStreamStore();
326
+ const ctx = createResumableStreamContext({
327
+ store,
328
+ onAppend: async () => {
329
+ throw hookError;
330
+ },
331
+ });
332
+
333
+ const stream = await ctx.run("a", () => makeStringStream(["hello"]));
334
+
335
+ expect(await collect(stream)).toBe("hello");
336
+ expect(await ctx.status("a")).toBe("done");
337
+ await vi.waitFor(() => {
338
+ expect(consoleError).toHaveBeenCalledWith(
339
+ "resumable stream onAppend hook failed:",
340
+ hookError,
341
+ );
342
+ });
343
+ });
344
+
345
+ it("keeps successful completion when onFinalize throws", async () => {
346
+ vi.spyOn(console, "error").mockImplementation(() => {});
347
+ const onError = vi.fn();
348
+ const onFinalize = vi.fn(() => {
349
+ throw new Error("finalize observer failed");
350
+ });
351
+ const store = createInMemoryResumableStreamStore();
352
+ const ctx = createResumableStreamContext({ store, onError, onFinalize });
353
+
354
+ const stream = await ctx.run("a", () => makeStringStream(["hello"]));
355
+
356
+ expect(await collect(stream)).toBe("hello");
357
+ expect(await ctx.status("a")).toBe("done");
358
+ expect(onFinalize).toHaveBeenCalledOnce();
359
+ expect(onError).not.toHaveBeenCalled();
360
+ });
361
+
362
+ it("finalizes producer errors when onError throws", async () => {
363
+ vi.spyOn(console, "error").mockImplementation(() => {});
364
+ const tasks: Promise<unknown>[] = [];
365
+ const store = createInMemoryResumableStreamStore();
366
+ const ctx = createResumableStreamContext({
367
+ store,
368
+ waitUntil: (task) => tasks.push(task),
369
+ onError: () => {
370
+ throw new Error("error observer failed");
371
+ },
372
+ });
373
+ const producerError = new Error("producer failed");
374
+
375
+ const stream = await ctx.run(
376
+ "a",
377
+ () =>
378
+ new ReadableStream<Uint8Array>({
379
+ start(controller) {
380
+ controller.error(producerError);
381
+ },
382
+ }),
383
+ );
384
+ await Promise.allSettled(tasks);
385
+
386
+ expect(await ctx.status("a")).toBe("error");
387
+ await expect(collect(stream)).rejects.toThrow("producer failed");
388
+ });
274
389
  });
@@ -1,4 +1,5 @@
1
1
  import { ResumableStreamError } from "./errors";
2
+ import { withPromiseOrValue } from "../core/utils/withPromiseOrValue";
2
3
  import type {
3
4
  ResumableStreamRole,
4
5
  ResumableStreamStatus,
@@ -42,6 +43,21 @@ export interface ResumableStreamContext {
42
43
  delete(streamId: string): Promise<void>;
43
44
  }
44
45
 
46
+ const invokeObservabilityHook = <TArgs extends unknown[]>(
47
+ name: string,
48
+ hook: ((...args: TArgs) => void) | undefined,
49
+ ...args: TArgs
50
+ ): void => {
51
+ if (!hook) return;
52
+ void withPromiseOrValue(
53
+ () => hook(...args),
54
+ () => {},
55
+ (error) => {
56
+ console.error(`resumable stream ${name} hook failed:`, error);
57
+ },
58
+ );
59
+ };
60
+
45
61
  export function createResumableStreamContext(
46
62
  options: ResumableStreamContextOptions,
47
63
  ): ResumableStreamContext {
@@ -53,7 +69,7 @@ export function createResumableStreamContext(
53
69
  return {
54
70
  async run(streamId, makeStream) {
55
71
  const role = await store.acquire(streamId, acquireOptions);
56
- onAcquire?.(streamId, role);
72
+ invokeObservabilityHook("onAcquire", onAcquire, streamId, role);
57
73
  if (role === "producer") {
58
74
  startProducerTask(store, streamId, makeStream, {
59
75
  waitUntil,
@@ -119,13 +135,18 @@ function startProducerTask(
119
135
  const { done, value } = await reader.read();
120
136
  if (done) break;
121
137
  await store.append(streamId, value);
122
- onAppend?.(streamId, value.byteLength);
138
+ invokeObservabilityHook(
139
+ "onAppend",
140
+ onAppend,
141
+ streamId,
142
+ value.byteLength,
143
+ );
123
144
  }
124
145
  await store.finalize(streamId, "done");
125
- onFinalize?.(streamId, "done");
146
+ invokeObservabilityHook("onFinalize", onFinalize, streamId, "done");
126
147
  } catch (err) {
127
148
  cancelled = true;
128
- onError?.(streamId, err);
149
+ invokeObservabilityHook("onError", onError, streamId, err);
129
150
  const message = err instanceof Error ? err.message : String(err);
130
151
  try {
131
152
  await reader?.cancel(err);
@@ -134,7 +155,13 @@ function startProducerTask(
134
155
  }
135
156
  try {
136
157
  await store.finalize(streamId, "error", message);
137
- onFinalize?.(streamId, "error", message);
158
+ invokeObservabilityHook(
159
+ "onFinalize",
160
+ onFinalize,
161
+ streamId,
162
+ "error",
163
+ message,
164
+ );
138
165
  } catch (finalizeErr) {
139
166
  console.error("resumable stream finalize failed:", finalizeErr);
140
167
  }