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
@@ -13,7 +13,7 @@ import type {
13
13
  FilePart,
14
14
  DataPart,
15
15
  } from "../utils/types";
16
- import { ObjectStreamAccumulator } from "../object/ObjectStreamAccumulator";
16
+ import { GorpStreamAccumulator } from "../gorp/GorpStreamAccumulator";
17
17
  import type { ReadonlyJSONValue } from "../../utils";
18
18
  import { TimingTracker } from "./TimingTracker";
19
19
 
@@ -37,20 +37,29 @@ export const createInitialMessage = ({
37
37
  },
38
38
  });
39
39
 
40
+ type WarnOnce = (key: string, message: string) => void;
41
+
42
+ const MAX_WARNED_KEYS = 16;
43
+
40
44
  const updatePartForPath = (
41
45
  message: AssistantMessage,
42
46
  chunk: AssistantStreamChunk,
47
+ warnOnce: WarnOnce,
43
48
  updater: (part: AssistantMessagePart) => AssistantMessagePart,
44
49
  ): AssistantMessage => {
45
- if (message.parts.length === 0) {
46
- throw new Error("No parts available to update.");
50
+ const part =
51
+ chunk.path.length === 1 ? message.parts[chunk.path[0]!] : undefined;
52
+ if (part === undefined) {
53
+ warnOnce(
54
+ `no-part:${chunk.type}`,
55
+ `Dropped ${chunk.type} chunk: no part at path [${chunk.path.join(", ")}]`,
56
+ );
57
+ return message;
47
58
  }
48
59
 
49
- if (chunk.path.length !== 1)
50
- throw new Error("Nested paths are not supported yet.");
51
-
52
60
  const partIndex = chunk.path[0]!;
53
- const updatedPart = updater(message.parts[partIndex]!);
61
+ const updatedPart = updater(part);
62
+ if (updatedPart === part) return message;
54
63
  return {
55
64
  ...message,
56
65
  parts: [
@@ -67,6 +76,7 @@ const updatePartForPath = (
67
76
  const handlePartStart = (
68
77
  message: AssistantMessage,
69
78
  chunk: AssistantStreamChunk & { readonly type: "part-start" },
79
+ warnOnce: WarnOnce,
70
80
  ): AssistantMessage => {
71
81
  const partInit = chunk.part;
72
82
  if (partInit.type === "text" || partInit.type === "reasoning") {
@@ -147,7 +157,24 @@ const handlePartStart = (
147
157
  },
148
158
  };
149
159
  } else {
150
- throw new Error(`Unsupported part type: ${partInit.type}`);
160
+ const unsupportedType = String((partInit as { type?: unknown }).type);
161
+ warnOnce(
162
+ `unsupported-part-start:${unsupportedType}`,
163
+ `Unsupported part-start type ${unsupportedType}: inserting an empty reasoning part to preserve part indices`,
164
+ );
165
+ // reasoning rather than a data sentinel: auiV0Encode rejects data parts, so a data placeholder would fail cloud persistence
166
+ const placeholderPart: ReasoningPart = {
167
+ type: "reasoning",
168
+ text: "",
169
+ status: { type: "running" },
170
+ };
171
+ return {
172
+ ...message,
173
+ parts: [...message.parts, placeholderPart],
174
+ get content() {
175
+ return this.parts;
176
+ },
177
+ };
151
178
  }
152
179
  };
153
180
 
@@ -156,14 +183,19 @@ const handleToolCallArgsTextFinish = (
156
183
  chunk: AssistantStreamChunk & {
157
184
  readonly type: "tool-call-args-text-finish";
158
185
  },
186
+ warnOnce: WarnOnce,
159
187
  ): AssistantMessage => {
160
- return updatePartForPath(message, chunk, (part) => {
188
+ return updatePartForPath(message, chunk, warnOnce, (part) => {
161
189
  if (part.type !== "tool-call") {
162
- throw new Error("Last is not a tool call");
190
+ warnOnce(
191
+ "wrong-part:tool-call-args-text-finish",
192
+ "Dropped tool-call-args-text-finish chunk: part is not a tool-call",
193
+ );
194
+ return part;
163
195
  }
164
196
 
165
197
  // TODO this should never be hit; this happens if args-text-finish is emitted after result
166
- if (part.state !== "partial-call") return part;
198
+ if (part.state !== "partial-call") return { ...part };
167
199
  // throw new Error("Last is not a partial call");
168
200
 
169
201
  return {
@@ -176,8 +208,9 @@ const handleToolCallArgsTextFinish = (
176
208
  const handlePartFinish = (
177
209
  message: AssistantMessage,
178
210
  chunk: AssistantStreamChunk & { readonly type: "part-finish" },
211
+ warnOnce: WarnOnce,
179
212
  ): AssistantMessage => {
180
- return updatePartForPath(message, chunk, (part) => ({
213
+ return updatePartForPath(message, chunk, warnOnce, (part) => ({
181
214
  ...part,
182
215
  status: { type: "complete", reason: "unknown" },
183
216
  }));
@@ -186,8 +219,9 @@ const handlePartFinish = (
186
219
  const handleTextDelta = (
187
220
  message: AssistantMessage,
188
221
  chunk: AssistantStreamChunk & { type: "text-delta" },
222
+ warnOnce: WarnOnce,
189
223
  ): AssistantMessage => {
190
- return updatePartForPath(message, chunk, (part) => {
224
+ return updatePartForPath(message, chunk, warnOnce, (part) => {
191
225
  if (part.type === "text" || part.type === "reasoning") {
192
226
  return { ...part, text: part.text + chunk.textDelta };
193
227
  } else if (part.type === "tool-call") {
@@ -198,9 +232,11 @@ const handleTextDelta = (
198
232
 
199
233
  return { ...part, argsText: newArgsText, args: newArgs };
200
234
  } else {
201
- throw new Error(
202
- "text-delta received but part is neither text nor tool-call",
235
+ warnOnce(
236
+ "wrong-part:text-delta",
237
+ "Dropped text-delta chunk: part is neither text nor tool-call",
203
238
  );
239
+ return part;
204
240
  }
205
241
  });
206
242
  };
@@ -208,8 +244,9 @@ const handleTextDelta = (
208
244
  const handleResult = (
209
245
  message: AssistantMessage,
210
246
  chunk: AssistantStreamChunk & { type: "result" },
247
+ warnOnce: WarnOnce,
211
248
  ): AssistantMessage => {
212
- return updatePartForPath(message, chunk, (part) => {
249
+ return updatePartForPath(message, chunk, warnOnce, (part) => {
213
250
  if (part.type === "tool-call") {
214
251
  return {
215
252
  ...part,
@@ -232,7 +269,11 @@ const handleResult = (
232
269
  status: { type: "complete", reason: "stop" },
233
270
  };
234
271
  } else {
235
- throw new Error("Result chunk received but part is not a tool-call");
272
+ warnOnce(
273
+ "wrong-part:result",
274
+ "Dropped result chunk: part is not a tool-call",
275
+ );
276
+ return part;
236
277
  }
237
278
  });
238
279
  };
@@ -377,7 +418,7 @@ const handleErrorChunk = (
377
418
  reason: "error",
378
419
  error: {
379
420
  code: chunk.code ?? "unknown",
380
- message: chunk.error,
421
+ message: chunk.error ?? "unknown error",
381
422
  ...(severity !== undefined && { severity }),
382
423
  },
383
424
  },
@@ -388,7 +429,7 @@ const handleUpdateState = (
388
429
  message: AssistantMessage,
389
430
  chunk: AssistantStreamChunk & { type: "update-state" },
390
431
  ): AssistantMessage => {
391
- const acc = new ObjectStreamAccumulator(message.metadata.unstable_state);
432
+ const acc = new GorpStreamAccumulator(message.metadata.unstable_state);
392
433
  acc.append(chunk.operations);
393
434
 
394
435
  return {
@@ -451,6 +492,12 @@ export class AssistantMessageAccumulator extends TransformStream<
451
492
  } = {}) {
452
493
  let message = initialMessage ?? createInitialMessage();
453
494
  const tracker = new TimingTracker();
495
+ const warnedKeys = new Set<string>();
496
+ const warnOnce: WarnOnce = (key, warning) => {
497
+ if (warnedKeys.has(key) || warnedKeys.size >= MAX_WARNED_KEYS) return;
498
+ warnedKeys.add(key);
499
+ console.warn(warning);
500
+ };
454
501
  let controller:
455
502
  | TransformStreamDefaultController<AssistantMessage>
456
503
  | undefined;
@@ -470,26 +517,28 @@ export class AssistantMessageAccumulator extends TransformStream<
470
517
  const type = chunk.type;
471
518
  switch (type) {
472
519
  case "part-start":
473
- message = handlePartStart(message, chunk);
520
+ message = handlePartStart(message, chunk, warnOnce);
474
521
  if (chunk.part.type === "tool-call") {
475
522
  tracker.recordToolCallStart(chunk.part.toolCallId);
476
523
  }
477
524
  break;
478
525
 
479
526
  case "tool-call-args-text-finish":
480
- message = handleToolCallArgsTextFinish(message, chunk);
527
+ message = handleToolCallArgsTextFinish(message, chunk, warnOnce);
481
528
  break;
482
529
 
483
530
  case "part-finish":
484
- message = handlePartFinish(message, chunk);
531
+ message = handlePartFinish(message, chunk, warnOnce);
485
532
  break;
486
533
 
487
- case "text-delta":
488
- message = handleTextDelta(message, chunk);
489
- tracker.recordFirstToken();
534
+ case "text-delta": {
535
+ const next = handleTextDelta(message, chunk, warnOnce);
536
+ if (next !== message) tracker.recordFirstToken();
537
+ message = next;
490
538
  break;
539
+ }
491
540
  case "result":
492
- message = handleResult(message, chunk);
541
+ message = handleResult(message, chunk, warnOnce);
493
542
  break;
494
543
  case "message-finish":
495
544
  message = handleMessageFinish(message, chunk);
@@ -1,11 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { createObjectStream } from "./createObjectStream";
3
- import {
4
- ObjectStreamEncoder,
5
- ObjectStreamDecoder,
6
- } from "./ObjectStreamResponse";
2
+ import { createGorpStream } from "./createGorpStream";
3
+ import { GorpStreamEncoder, GorpStreamDecoder } from "./GorpStreamResponse";
7
4
  import type { ReadonlyJSONValue } from "../../utils";
8
- import type { ObjectStreamChunk } from "./types";
5
+ import type { GorpStreamChunk } from "./types";
9
6
 
10
7
  // Helper function to collect all chunks from a stream
11
8
  async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> {
@@ -27,10 +24,10 @@ async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> {
27
24
 
28
25
  // Helper function to encode and decode a stream
29
26
  async function encodeAndDecode(
30
- stream: ReadableStream<ObjectStreamChunk>,
31
- ): Promise<ReadableStream<ObjectStreamChunk>> {
27
+ stream: ReadableStream<GorpStreamChunk>,
28
+ ): Promise<ReadableStream<GorpStreamChunk>> {
32
29
  // Encode the stream to Uint8Array (simulating network transmission)
33
- const encodedStream = stream.pipeThrough(new ObjectStreamEncoder());
30
+ const encodedStream = stream.pipeThrough(new GorpStreamEncoder());
34
31
 
35
32
  // Collect all encoded chunks
36
33
  const encodedChunks = await collectChunks(encodedStream);
@@ -45,13 +42,37 @@ async function encodeAndDecode(
45
42
  },
46
43
  });
47
44
 
48
- // Decode the stream back to ObjectStreamChunk
49
- return reconstructedStream.pipeThrough(new ObjectStreamDecoder());
45
+ // Decode the stream back to GorpStreamChunk
46
+ return reconstructedStream.pipeThrough(new GorpStreamDecoder());
50
47
  }
51
48
 
52
- describe("ObjectStream serialization and deserialization", () => {
49
+ describe("GorpStream serialization and deserialization", () => {
50
+ it("does not settle the stream after cancellation", async () => {
51
+ let finishExecution!: () => void;
52
+ let abortSignal!: AbortSignal;
53
+ const execution = new Promise<void>((resolve) => {
54
+ finishExecution = resolve;
55
+ });
56
+ const stream = createGorpStream({
57
+ execute: (controller) => {
58
+ abortSignal = controller.abortSignal;
59
+ return execution;
60
+ },
61
+ });
62
+ const reader = stream.getReader();
63
+
64
+ await reader.cancel("consumer stopped");
65
+
66
+ expect(abortSignal.aborted).toBe(true);
67
+ expect(abortSignal.reason).toBe("consumer stopped");
68
+
69
+ finishExecution();
70
+ await execution;
71
+ await new Promise((resolve) => setTimeout(resolve, 0));
72
+ });
73
+
53
74
  it("should discard an unterminated SSE event", async () => {
54
- const operations: ObjectStreamChunk["operations"] = [
75
+ const operations: GorpStreamChunk["operations"] = [
55
76
  { type: "set", path: ["status"], value: "complete" },
56
77
  ];
57
78
  const stream = new ReadableStream<Uint8Array>({
@@ -64,7 +85,7 @@ describe("ObjectStream serialization and deserialization", () => {
64
85
  });
65
86
 
66
87
  const chunks = await collectChunks(
67
- stream.pipeThrough(new ObjectStreamDecoder()),
88
+ stream.pipeThrough(new GorpStreamDecoder()),
68
89
  );
69
90
 
70
91
  expect(chunks).toEqual([]);
@@ -72,7 +93,7 @@ describe("ObjectStream serialization and deserialization", () => {
72
93
 
73
94
  it("should correctly serialize and deserialize simple objects", async () => {
74
95
  // Create an object stream with simple operations
75
- const stream = createObjectStream({
96
+ const stream = createGorpStream({
76
97
  execute: (controller) => {
77
98
  controller.enqueue([
78
99
  { type: "set", path: ["name"], value: "John" },
@@ -96,7 +117,7 @@ describe("ObjectStream serialization and deserialization", () => {
96
117
  });
97
118
 
98
119
  it("should correctly handle nested objects", async () => {
99
- const stream = createObjectStream({
120
+ const stream = createGorpStream({
100
121
  execute: (controller) => {
101
122
  controller.enqueue([
102
123
  { type: "set", path: ["user", "profile", "name"], value: "Jane" },
@@ -128,7 +149,7 @@ describe("ObjectStream serialization and deserialization", () => {
128
149
  });
129
150
 
130
151
  it("should correctly handle arrays", async () => {
131
- const stream = createObjectStream({
152
+ const stream = createGorpStream({
132
153
  execute: (controller) => {
133
154
  controller.enqueue([
134
155
  { type: "set", path: ["items"], value: [] },
@@ -149,7 +170,7 @@ describe("ObjectStream serialization and deserialization", () => {
149
170
  });
150
171
 
151
172
  it("should correctly handle mixed arrays and objects", async () => {
152
- const stream = createObjectStream({
173
+ const stream = createGorpStream({
153
174
  execute: (controller) => {
154
175
  controller.enqueue([
155
176
  { type: "set", path: ["users"], value: [] },
@@ -176,7 +197,7 @@ describe("ObjectStream serialization and deserialization", () => {
176
197
  });
177
198
 
178
199
  it("should correctly handle append-text operations", async () => {
179
- const stream = createObjectStream({
200
+ const stream = createGorpStream({
180
201
  execute: (controller) => {
181
202
  controller.enqueue([
182
203
  { type: "set", path: ["message"], value: "Hello" },
@@ -196,8 +217,48 @@ describe("ObjectStream serialization and deserialization", () => {
196
217
  });
197
218
  });
198
219
 
220
+ it("does not apply first-chunk operations twice", async () => {
221
+ const stream = createGorpStream({
222
+ defaultValue: { message: "Hello" },
223
+ execute: (controller) => {
224
+ controller.enqueue([
225
+ { type: "append-text", path: ["message"], value: " world" },
226
+ ]);
227
+ controller.enqueue([
228
+ { type: "append-text", path: ["message"], value: "!" },
229
+ ]);
230
+ },
231
+ });
232
+
233
+ const decodedStream = await encodeAndDecode(stream);
234
+ const chunks = await collectChunks(decodedStream);
235
+
236
+ expect(chunks).toEqual([
237
+ {
238
+ snapshot: { message: "Hello world" },
239
+ operations: [
240
+ {
241
+ type: "set",
242
+ path: [],
243
+ value: { message: "Hello world" },
244
+ },
245
+ ],
246
+ },
247
+ {
248
+ snapshot: { message: "Hello world!" },
249
+ operations: [
250
+ {
251
+ type: "append-text",
252
+ path: ["message"],
253
+ value: "!",
254
+ },
255
+ ],
256
+ },
257
+ ]);
258
+ });
259
+
199
260
  it("should correctly handle special characters and Unicode", async () => {
200
- const stream = createObjectStream({
261
+ const stream = createGorpStream({
201
262
  execute: (controller) => {
202
263
  controller.enqueue([
203
264
  {
@@ -229,7 +290,7 @@ describe("ObjectStream serialization and deserialization", () => {
229
290
  });
230
291
 
231
292
  it("should correctly handle null and undefined values", async () => {
232
- const stream = createObjectStream({
293
+ const stream = createGorpStream({
233
294
  execute: (controller) => {
234
295
  controller.enqueue([
235
296
  { type: "set", path: ["nullValue"], value: null },
@@ -252,7 +313,7 @@ describe("ObjectStream serialization and deserialization", () => {
252
313
 
253
314
  it("should correctly handle large nested structures", async () => {
254
315
  // Create a deep nested structure
255
- const stream = createObjectStream({
316
+ const stream = createGorpStream({
256
317
  execute: (controller) => {
257
318
  controller.enqueue([
258
319
  { type: "set", path: ["level1"], value: {} },
@@ -290,7 +351,7 @@ describe("ObjectStream serialization and deserialization", () => {
290
351
  });
291
352
 
292
353
  it("should correctly handle operations in multiple enqueue calls", async () => {
293
- const stream = createObjectStream({
354
+ const stream = createGorpStream({
294
355
  execute: (controller) => {
295
356
  // First batch of operations
296
357
  controller.enqueue([
@@ -339,7 +400,7 @@ describe("ObjectStream serialization and deserialization", () => {
339
400
  });
340
401
 
341
402
  it("should correctly handle overwriting existing values", async () => {
342
- const stream = createObjectStream({
403
+ const stream = createGorpStream({
343
404
  execute: (controller) => {
344
405
  controller.enqueue([
345
406
  { type: "set", path: ["value"], value: "initial" },
@@ -371,7 +432,7 @@ describe("ObjectStream serialization and deserialization", () => {
371
432
  },
372
433
  };
373
434
 
374
- const stream = createObjectStream({
435
+ const stream = createGorpStream({
375
436
  defaultValue: initialValue,
376
437
  execute: (controller) => {
377
438
  controller.enqueue([
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { GorpStreamAccumulator } from "./GorpStreamAccumulator";
3
+
4
+ describe("GorpStreamAccumulator", () => {
5
+ it("rejects unsafe path segments", () => {
6
+ for (const path of [
7
+ ["__proto__", "polluted"],
8
+ ["constructor", "prototype", "polluted"],
9
+ ["a", "__proto__"],
10
+ ]) {
11
+ const acc = new GorpStreamAccumulator({});
12
+ expect(() => acc.append([{ type: "set", path, value: true }])).toThrow(
13
+ /Unsafe gorp path segment/,
14
+ );
15
+ }
16
+ expect(({} as { polluted?: unknown }).polluted).toBeUndefined();
17
+ });
18
+
19
+ it("treats inherited keys as absent when navigating", () => {
20
+ const acc = new GorpStreamAccumulator({});
21
+ acc.append([{ type: "set", path: ["toString", "x"], value: 1 }]);
22
+ expect(acc.state).toEqual({ toString: { x: 1 } });
23
+
24
+ const append = new GorpStreamAccumulator({ toString: "hi" });
25
+ append.append([{ type: "append-text", path: ["toString"], value: "!" }]);
26
+ expect(append.state).toEqual({ toString: "hi!" });
27
+ });
28
+ });
@@ -1,7 +1,8 @@
1
1
  import type { ReadonlyJSONValue, ReadonlyJSONObject } from "../../utils";
2
- import type { ObjectStreamOperation } from "./types";
2
+ import { assertSafePathSegment } from "./changeTree";
3
+ import type { GorpStreamOperation } from "./types";
3
4
 
4
- export class ObjectStreamAccumulator {
5
+ export class GorpStreamAccumulator {
5
6
  private _state: ReadonlyJSONValue;
6
7
 
7
8
  constructor(initialValue: ReadonlyJSONValue = null) {
@@ -12,24 +13,20 @@ export class ObjectStreamAccumulator {
12
13
  return this._state;
13
14
  }
14
15
 
15
- append(ops: readonly ObjectStreamOperation[]) {
16
+ append(ops: readonly GorpStreamOperation[]) {
16
17
  this._state = ops.reduce(
17
- (state, op) => ObjectStreamAccumulator.apply(state, op),
18
+ (state, op) => GorpStreamAccumulator.apply(state, op),
18
19
  this._state,
19
20
  );
20
21
  }
21
22
 
22
- private static apply(state: ReadonlyJSONValue, op: ObjectStreamOperation) {
23
+ private static apply(state: ReadonlyJSONValue, op: GorpStreamOperation) {
23
24
  const type = op.type;
24
25
  switch (type) {
25
26
  case "set":
26
- return ObjectStreamAccumulator.updatePath(
27
- state,
28
- op.path,
29
- () => op.value,
30
- );
27
+ return GorpStreamAccumulator.updatePath(state, op.path, () => op.value);
31
28
  case "append-text":
32
- return ObjectStreamAccumulator.updatePath(state, op.path, (current) => {
29
+ return GorpStreamAccumulator.updatePath(state, op.path, (current) => {
33
30
  if (typeof current !== "string")
34
31
  throw new Error(`Expected string at path [${op.path.join(", ")}]`);
35
32
  return current + op.value;
@@ -57,6 +54,7 @@ export class ObjectStreamAccumulator {
57
54
  }
58
55
 
59
56
  const [key, ...rest] = path as [string, ...(readonly string[])];
57
+ assertSafePathSegment(key);
60
58
  if (Array.isArray(state)) {
61
59
  const idx = Number(key);
62
60
  if (Number.isNaN(idx))
@@ -65,8 +63,8 @@ export class ObjectStreamAccumulator {
65
63
  throw new Error(`Insert array index out of bounds`);
66
64
 
67
65
  const nextState = [...state];
68
- nextState[idx] = ObjectStreamAccumulator.updatePath(
69
- nextState[idx],
66
+ nextState[idx] = GorpStreamAccumulator.updatePath(
67
+ Object.hasOwn(nextState, idx) ? nextState[idx] : undefined,
70
68
  rest,
71
69
  updater,
72
70
  );
@@ -75,8 +73,8 @@ export class ObjectStreamAccumulator {
75
73
  }
76
74
 
77
75
  const nextState = { ...(state as ReadonlyJSONObject) };
78
- nextState[key] = ObjectStreamAccumulator.updatePath(
79
- nextState[key],
76
+ nextState[key] = GorpStreamAccumulator.updatePath(
77
+ Object.hasOwn(nextState, key) ? nextState[key] : undefined,
80
78
  rest,
81
79
  updater,
82
80
  );