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,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
- controller.enqueue(JSON.parse(event.data));
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:
@@ -2,7 +2,7 @@ import type {
2
2
  ReadonlyJSONObject,
3
3
  ReadonlyJSONValue,
4
4
  } from "../../../utils/json/json-value";
5
- import type { ObjectStreamOperation } from "../../object/types";
5
+ import type { GorpStreamOperation } from "../../gorp/types";
6
6
 
7
7
  export type DataStreamChunk = {
8
8
  [K in DataStreamStreamChunkType]: {
@@ -100,7 +100,7 @@ type DataStreamStreamChunkValue = {
100
100
  [DataStreamStreamChunkType.File]: { data: string; mimeType: string };
101
101
 
102
102
  // aui-extensions
103
- [DataStreamStreamChunkType.AuiUpdateStateOperations]: ObjectStreamOperation[];
103
+ [DataStreamStreamChunkType.AuiUpdateStateOperations]: GorpStreamOperation[];
104
104
  [DataStreamStreamChunkType.AuiTextDelta]: {
105
105
  textDelta: string;
106
106
  parentId: string;
@@ -575,4 +575,115 @@ 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 that are not valid JSON", async () => {
608
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
609
+ const invalidFrames = ['{"type":"te', "not json"];
610
+ const events = [
611
+ ...invalidFrames,
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.mock.calls.map((c) => c[0])).toEqual(
622
+ invalidFrames.map((f) => `Dropped invalid UIMessageStream chunk: ${f}`),
623
+ );
624
+ expect(chunks.some((c) => c.type === "text-delta")).toBe(true);
625
+ warn.mockRestore();
626
+ });
627
+
628
+ it("ignores a tool-call-delta arriving after tool-result", async () => {
629
+ const events = [
630
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
631
+ JSON.stringify({
632
+ type: "tool-call-start",
633
+ toolCallId: "call_abc",
634
+ toolName: "weather",
635
+ }),
636
+ JSON.stringify({ type: "tool-call-delta", argsText: '{"city":"NYC"}' }),
637
+ JSON.stringify({
638
+ type: "tool-result",
639
+ toolCallId: "call_abc",
640
+ result: { temp: 72 },
641
+ }),
642
+ JSON.stringify({ type: "tool-call-delta", argsText: '{"late":true}' }),
643
+ "[DONE]",
644
+ ];
645
+
646
+ const chunks = await collectChunks(
647
+ createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
648
+ );
649
+
650
+ const argDeltas = chunks.filter((c) => c.type === "text-delta");
651
+ expect(argDeltas).toHaveLength(1);
652
+ expect(chunks.some((c) => c.type === "result")).toBe(true);
653
+ });
654
+
655
+ it("keeps the active tool call writable when another call receives its result", async () => {
656
+ const events = [
657
+ JSON.stringify({
658
+ type: "tool-call-start",
659
+ toolCallId: "call_a",
660
+ toolName: "first",
661
+ }),
662
+ JSON.stringify({
663
+ type: "tool-call-start",
664
+ toolCallId: "call_b",
665
+ toolName: "second",
666
+ }),
667
+ JSON.stringify({
668
+ type: "tool-result",
669
+ toolCallId: "call_a",
670
+ result: { ok: true },
671
+ }),
672
+ JSON.stringify({ type: "tool-call-delta", argsText: '{"x":1}' }),
673
+ "[DONE]",
674
+ ];
675
+
676
+ const chunks = await collectChunks(
677
+ createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
678
+ );
679
+
680
+ const argDeltas = chunks.filter(
681
+ (c): c is AssistantStreamChunk & { type: "text-delta" } =>
682
+ c.type === "text-delta",
683
+ );
684
+ expect(argDeltas).toHaveLength(2);
685
+ const byPath = new Map(argDeltas.map((c) => [c.path[0], c.textDelta]));
686
+ expect(byPath.get(0)).toBe("{}");
687
+ expect(byPath.get(1)).toBe('{"x":1}');
688
+ });
578
689
  });
@@ -149,6 +149,9 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
149
149
  `Encountered tool result with unknown id: ${chunk.toolCallId}`,
150
150
  );
151
151
  }
152
+ if (toolCallController.argsText === activeToolCallArgsText) {
153
+ activeToolCallArgsText = undefined;
154
+ }
152
155
  toolCallController.setResponse({
153
156
  result: chunk.result,
154
157
  isError: chunk.isError ?? false,
@@ -222,7 +225,23 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
222
225
  return;
223
226
  }
224
227
 
225
- const chunk = JSON.parse(event.data);
228
+ let chunk;
229
+ try {
230
+ chunk = JSON.parse(event.data);
231
+ } catch {
232
+ chunk = undefined;
233
+ }
234
+ if (
235
+ typeof chunk !== "object" ||
236
+ chunk === null ||
237
+ Array.isArray(chunk) ||
238
+ typeof chunk.type !== "string"
239
+ ) {
240
+ console.warn(
241
+ `Dropped invalid UIMessageStream chunk: ${event.data.slice(0, 200)}`,
242
+ );
243
+ return;
244
+ }
226
245
  if (
227
246
  chunk.type === "text-delta" &&
228
247
  chunk.textDelta === undefined
@@ -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
  }
@@ -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);
@@ -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,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as entry from "./index";
3
+
4
+ describe("assistant-transport state exports", () => {
5
+ it("exposes the assistant-transport surface and no gorp-shaped names", () => {
6
+ expect(typeof entry.AssistantTransportDeltaTracker).toBe("function");
7
+ expect(typeof entry.createObjectStream).toBe("function");
8
+ expect(typeof entry.ObjectStreamResponse).toBe("function");
9
+ expect(typeof entry.fromObjectStreamResponse).toBe("function");
10
+ for (const name of Object.keys(entry)) {
11
+ expect(name.toLowerCase()).not.toContain("gorp");
12
+ }
13
+ });
14
+
15
+ it("streams operations end to end through the SSE wire", async () => {
16
+ const stream = entry.createObjectStream({
17
+ execute: (controller) => {
18
+ controller.enqueue([
19
+ { type: "set", path: ["message"], value: "Hello" },
20
+ ]);
21
+ controller.enqueue([
22
+ { type: "append-text", path: ["message"], value: " World" },
23
+ ]);
24
+ },
25
+ });
26
+
27
+ const response = new entry.ObjectStreamResponse(stream);
28
+ const decoded = entry.fromObjectStreamResponse(response);
29
+
30
+ const tracker = new entry.AssistantTransportDeltaTracker();
31
+ const reader = decoded.getReader();
32
+ while (true) {
33
+ const { done, value } = await reader.read();
34
+ if (done) break;
35
+ tracker.append(value.operations);
36
+ expect(tracker.state).toEqual(value.snapshot);
37
+ }
38
+
39
+ expect(tracker.state).toEqual({ message: "Hello World" });
40
+ expect(tracker.isChangedAt(["message"])).toBe(true);
41
+ });
42
+ });
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
  });