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.
- package/dist/core/AssistantStream.d.ts.map +1 -1
- package/dist/core/AssistantStreamChunk.d.ts +3 -3
- package/dist/core/accumulators/AssistantMessageStream.d.ts.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.js +65 -28
- package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
- package/dist/core/converters/toGenericMessages.js.map +1 -1
- package/dist/core/gorp/GorpStreamAccumulator.d.ts +15 -0
- package/dist/core/gorp/GorpStreamAccumulator.d.ts.map +1 -0
- package/dist/core/{object/ObjectStreamAccumulator.js → gorp/GorpStreamAccumulator.js} +11 -9
- package/dist/core/gorp/GorpStreamAccumulator.js.map +1 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.d.ts +17 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.d.ts.map +1 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.js +38 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.js.map +1 -0
- package/dist/core/gorp/GorpStreamResponse.d.ts +16 -0
- package/dist/core/gorp/GorpStreamResponse.d.ts.map +1 -0
- package/dist/core/gorp/GorpStreamResponse.js +51 -0
- package/dist/core/gorp/GorpStreamResponse.js.map +1 -0
- package/dist/core/gorp/changeTree.d.ts +16 -0
- package/dist/core/gorp/changeTree.d.ts.map +1 -0
- package/dist/core/gorp/changeTree.js +77 -0
- package/dist/core/gorp/changeTree.js.map +1 -0
- package/dist/core/gorp/createGorpStream.d.ts +16 -0
- package/dist/core/gorp/createGorpStream.d.ts.map +1 -0
- package/dist/core/{object/createObjectStream.js → gorp/createGorpStream.js} +13 -8
- package/dist/core/gorp/createGorpStream.js.map +1 -0
- package/dist/core/{object → gorp}/types.d.ts +6 -5
- package/dist/core/gorp/types.d.ts.map +1 -0
- package/dist/core/modules/assistant-stream.d.ts +5 -4
- package/dist/core/modules/assistant-stream.d.ts.map +1 -1
- package/dist/core/modules/assistant-stream.js +10 -6
- package/dist/core/modules/assistant-stream.js.map +1 -1
- package/dist/core/modules/tool-call.js +2 -2
- package/dist/core/modules/tool-call.js.map +1 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts.map +1 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.js +91 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.js.map +1 -1
- package/dist/core/serialization/data-stream/chunk-types.d.ts +2 -2
- package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.js +11 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
- package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
- package/dist/core/tool/ToolCallReader.js.map +1 -1
- package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
- package/dist/core/tool/ToolExecutionStream.js +9 -1
- package/dist/core/tool/ToolExecutionStream.js.map +1 -1
- package/dist/core/tool/ToolResponse.d.ts.map +1 -1
- package/dist/core/tool/toolResultStream.d.ts.map +1 -1
- package/dist/core/tool/toolResultStream.js +7 -2
- package/dist/core/tool/toolResultStream.js.map +1 -1
- package/dist/core/utils/stream/AssistantTransformStream.d.ts.map +1 -1
- package/dist/core/utils/stream/PipeableTransformStream.d.ts.map +1 -1
- package/dist/core/utils/stream/SSEEventDecoder.d.ts.map +1 -1
- package/dist/core/utils/stream/merge.d.ts +2 -0
- package/dist/core/utils/stream/merge.d.ts.map +1 -1
- package/dist/core/utils/stream/merge.js +27 -8
- package/dist/core/utils/stream/merge.js.map +1 -1
- package/dist/core/utils/stream/path-utils.d.ts.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -0
- package/dist/resumable/ResumableStreamContext.d.ts.map +1 -1
- package/dist/resumable/ResumableStreamContext.js +12 -5
- package/dist/resumable/ResumableStreamContext.js.map +1 -1
- package/dist/resumable/errors.d.ts.map +1 -1
- package/dist/resumable/stores/redis-impl.d.ts.map +1 -1
- package/dist/utils/AsyncIterableStream.d.ts.map +1 -1
- package/dist/utils/AsyncIterableStream.js +18 -3
- package/dist/utils/AsyncIterableStream.js.map +1 -1
- package/dist/utils/promiseWithResolvers.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/AssistantStreamChunk.ts +3 -3
- package/src/core/accumulators/assistant-message-accumulator.test.ts +374 -1
- package/src/core/accumulators/assistant-message-accumulator.ts +75 -26
- package/src/core/{object/ObjectStream.test.ts → gorp/GorpStream.test.ts} +86 -25
- package/src/core/gorp/GorpStreamAccumulator.test.ts +28 -0
- package/src/core/{object/ObjectStreamAccumulator.ts → gorp/GorpStreamAccumulator.ts} +13 -15
- package/src/core/gorp/GorpStreamDeltaTracker.test.ts +182 -0
- package/src/core/gorp/GorpStreamDeltaTracker.ts +55 -0
- package/src/core/{object/ObjectStreamResponse.test.ts → gorp/GorpStreamResponse.test.ts} +6 -6
- package/src/core/gorp/GorpStreamResponse.ts +88 -0
- package/src/core/gorp/changeTree.test.ts +168 -0
- package/src/core/gorp/changeTree.ts +96 -0
- package/src/core/{object/createObjectStream.ts → gorp/createGorpStream.ts} +23 -17
- package/src/core/{object → gorp}/types.ts +5 -3
- package/src/core/modules/assistant-stream.test.ts +154 -2
- package/src/core/modules/assistant-stream.ts +16 -6
- package/src/core/modules/tool-call.test.ts +67 -0
- package/src/core/modules/tool-call.ts +3 -3
- package/src/core/serialization/assistant-transport/AssistantTransport.test.ts +174 -1
- package/src/core/serialization/assistant-transport/AssistantTransport.ts +77 -1
- package/src/core/serialization/data-stream/chunk-types.ts +2 -2
- package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +111 -0
- package/src/core/serialization/ui-message-stream/UIMessageStream.ts +20 -1
- package/src/core/tool/ToolExecutionStream.ts +10 -0
- package/src/core/tool/toolResultStream.test.ts +50 -0
- package/src/core/tool/toolResultStream.ts +7 -2
- package/src/core/utils/stream/merge.ts +31 -9
- package/src/gorp.test.ts +42 -0
- package/src/index.ts +21 -6
- package/src/resumable/ResumableStreamContext.test.ts +116 -1
- package/src/resumable/ResumableStreamContext.ts +32 -5
- package/src/resumable/createResumableAssistantStreamResponse.test.ts +1 -25
- package/src/utils/AsyncIterableStream.test.ts +61 -0
- package/src/utils/AsyncIterableStream.ts +18 -3
- package/dist/core/object/ObjectStreamAccumulator.d.ts +0 -15
- package/dist/core/object/ObjectStreamAccumulator.d.ts.map +0 -1
- package/dist/core/object/ObjectStreamAccumulator.js.map +0 -1
- package/dist/core/object/ObjectStreamResponse.d.ts +0 -16
- package/dist/core/object/ObjectStreamResponse.d.ts.map +0 -1
- package/dist/core/object/ObjectStreamResponse.js +0 -57
- package/dist/core/object/ObjectStreamResponse.js.map +0 -1
- package/dist/core/object/createObjectStream.d.ts +0 -16
- package/dist/core/object/createObjectStream.d.ts.map +0 -1
- package/dist/core/object/createObjectStream.js.map +0 -1
- package/dist/core/object/types.d.ts.map +0 -1
- package/src/core/object/ObjectStreamResponse.ts +0 -114
- /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 {
|
|
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
|
-
|
|
46
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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<
|
|
31
|
-
): Promise<ReadableStream<
|
|
27
|
+
stream: ReadableStream<GorpStreamChunk>,
|
|
28
|
+
): Promise<ReadableStream<GorpStreamChunk>> {
|
|
32
29
|
// Encode the stream to Uint8Array (simulating network transmission)
|
|
33
|
-
const encodedStream = stream.pipeThrough(new
|
|
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
|
|
49
|
-
return reconstructedStream.pipeThrough(new
|
|
45
|
+
// Decode the stream back to GorpStreamChunk
|
|
46
|
+
return reconstructedStream.pipeThrough(new GorpStreamDecoder());
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
describe("
|
|
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:
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
2
|
+
import { assertSafePathSegment } from "./changeTree";
|
|
3
|
+
import type { GorpStreamOperation } from "./types";
|
|
3
4
|
|
|
4
|
-
export class
|
|
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
|
|
16
|
+
append(ops: readonly GorpStreamOperation[]) {
|
|
16
17
|
this._state = ops.reduce(
|
|
17
|
-
(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:
|
|
23
|
+
private static apply(state: ReadonlyJSONValue, op: GorpStreamOperation) {
|
|
23
24
|
const type = op.type;
|
|
24
25
|
switch (type) {
|
|
25
26
|
case "set":
|
|
26
|
-
return
|
|
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
|
|
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] =
|
|
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] =
|
|
79
|
-
nextState[key],
|
|
76
|
+
nextState[key] = GorpStreamAccumulator.updatePath(
|
|
77
|
+
Object.hasOwn(nextState, key) ? nextState[key] : undefined,
|
|
80
78
|
rest,
|
|
81
79
|
updater,
|
|
82
80
|
);
|