assistant-stream 0.3.27 → 0.3.29
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/accumulators/AssistantMessageStream.d.ts.map +1 -1
- package/dist/core/accumulators/AssistantMessageStream.js +0 -1
- package/dist/core/accumulators/AssistantMessageStream.js.map +1 -1
- package/dist/core/modules/tool-call.d.ts.map +1 -1
- package/dist/core/modules/tool-call.js +1 -1
- package/dist/core/modules/tool-call.js.map +1 -1
- package/dist/core/serialization/PlainText.d.ts.map +1 -1
- package/dist/core/serialization/PlainText.js +4 -18
- package/dist/core/serialization/PlainText.js.map +1 -1
- package/dist/core/serialization/data-stream/DataStream.js +48 -48
- package/dist/core/serialization/data-stream/DataStream.js.map +1 -1
- package/dist/core/serialization/data-stream/chunk-types.d.ts +23 -22
- package/dist/core/serialization/data-stream/chunk-types.d.ts.map +1 -1
- package/dist/core/serialization/data-stream/chunk-types.js +22 -23
- package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
- package/dist/core/serialization/data-stream/serialization.d.ts.map +1 -1
- package/dist/core/serialization/data-stream/serialization.js +14 -2
- package/dist/core/serialization/data-stream/serialization.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 +2 -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/tool-types.d.ts +6 -0
- package/dist/core/tool/tool-types.d.ts.map +1 -1
- package/dist/core/utils/stream/SSE.d.ts.map +1 -1
- package/dist/core/utils/stream/SSE.js +11 -2
- package/dist/core/utils/stream/SSE.js.map +1 -1
- package/package.json +2 -2
- package/src/core/accumulators/AssistantMessageStream.ts +3 -1
- package/src/core/converters/toGenericMessages.test.ts +3 -39
- package/src/core/gorp/GorpStreamDeltaTracker.test.ts +30 -0
- package/src/core/modules/tool-call.ts +4 -1
- package/src/core/serialization/PlainText.test.ts +54 -0
- package/src/core/serialization/PlainText.ts +8 -27
- package/src/core/serialization/data-stream/DataStream.ts +1 -1
- package/src/core/serialization/data-stream/chunk-types.ts +24 -22
- package/src/core/serialization/data-stream/serialization.test.ts +133 -0
- package/src/core/serialization/data-stream/serialization.ts +20 -2
- package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +24 -0
- package/src/core/serialization/ui-message-stream/UIMessageStream.ts +2 -1
- package/src/core/tool/ToolCallReader.ts +5 -1
- package/src/core/tool/tool-types.ts +7 -0
- package/src/core/utils/stream/SSE.test.ts +97 -0
- package/src/core/utils/stream/SSE.ts +13 -2
- package/src/gorp.test.ts +0 -10
- package/src/core/accumulators/TimingTracker.test.ts +0 -100
- package/src/core/gorp/changeTree.test.ts +0 -168
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { AssistantStreamEncoder } from "../AssistantStream";
|
|
2
2
|
import type { AssistantStreamChunk } from "../AssistantStreamChunk";
|
|
3
|
+
import {
|
|
4
|
+
AssistantMetaTransformStream,
|
|
5
|
+
type AssistantMetaStreamChunk,
|
|
6
|
+
} from "../utils/stream/AssistantMetaTransformStream";
|
|
3
7
|
import { AssistantTransformStream } from "../utils/stream/AssistantTransformStream";
|
|
4
8
|
import { PipeableTransformStream } from "../utils/stream/PipeableTransformStream";
|
|
5
9
|
|
|
@@ -9,43 +13,20 @@ export class PlainTextEncoder
|
|
|
9
13
|
{
|
|
10
14
|
headers = new Headers({
|
|
11
15
|
"Content-Type": "text/plain; charset=utf-8",
|
|
12
|
-
"x-vercel-ai-data-stream": "v1",
|
|
13
16
|
});
|
|
14
17
|
|
|
15
18
|
constructor() {
|
|
16
19
|
super((readable) => {
|
|
17
|
-
const transform = new TransformStream<
|
|
20
|
+
const transform = new TransformStream<AssistantMetaStreamChunk, string>({
|
|
18
21
|
transform(chunk, controller) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
case "text-delta":
|
|
22
|
-
controller.enqueue(chunk.textDelta);
|
|
23
|
-
break;
|
|
24
|
-
|
|
25
|
-
case "part-start":
|
|
26
|
-
case "part-finish":
|
|
27
|
-
case "step-start":
|
|
28
|
-
case "step-finish":
|
|
29
|
-
case "message-finish":
|
|
30
|
-
case "error":
|
|
31
|
-
break;
|
|
32
|
-
|
|
33
|
-
default: {
|
|
34
|
-
const unsupportedType:
|
|
35
|
-
| "tool-call-args-text-finish"
|
|
36
|
-
| "data"
|
|
37
|
-
| "annotations"
|
|
38
|
-
| "tool-call-begin"
|
|
39
|
-
| "tool-call-delta"
|
|
40
|
-
| "result"
|
|
41
|
-
| "update-state" = type;
|
|
42
|
-
throw new Error(`unsupported chunk type: ${unsupportedType}`);
|
|
43
|
-
}
|
|
22
|
+
if (chunk.type === "text-delta" && chunk.meta.type === "text") {
|
|
23
|
+
controller.enqueue(chunk.textDelta);
|
|
44
24
|
}
|
|
45
25
|
},
|
|
46
26
|
});
|
|
47
27
|
|
|
48
28
|
return readable
|
|
29
|
+
.pipeThrough(new AssistantMetaTransformStream())
|
|
49
30
|
.pipeThrough(transform)
|
|
50
31
|
.pipeThrough(new TextEncoderStream());
|
|
51
32
|
});
|
|
@@ -208,7 +208,7 @@ export class DataStreamEncoder
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
const TOOL_CALL_ARGS_CLOSING_CHUNKS = [
|
|
211
|
+
const TOOL_CALL_ARGS_CLOSING_CHUNKS: DataStreamStreamChunkType[] = [
|
|
212
212
|
DataStreamStreamChunkType.StartToolCall,
|
|
213
213
|
DataStreamStreamChunkType.ToolCall,
|
|
214
214
|
DataStreamStreamChunkType.TextDelta,
|
|
@@ -25,29 +25,31 @@ type LanguageModelV1Usage = {
|
|
|
25
25
|
outputTokens: number;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
export
|
|
29
|
-
TextDelta
|
|
30
|
-
Data
|
|
31
|
-
Error
|
|
32
|
-
Annotation
|
|
33
|
-
ToolCall
|
|
34
|
-
ToolCallResult
|
|
35
|
-
StartToolCall
|
|
36
|
-
ToolCallArgsTextDelta
|
|
37
|
-
FinishMessage
|
|
38
|
-
FinishStep
|
|
39
|
-
StartStep
|
|
40
|
-
ReasoningDelta
|
|
41
|
-
Source
|
|
42
|
-
RedactedReasoning
|
|
43
|
-
ReasoningSignature
|
|
44
|
-
File
|
|
28
|
+
export const DataStreamStreamChunkType = {
|
|
29
|
+
TextDelta: "0",
|
|
30
|
+
Data: "2",
|
|
31
|
+
Error: "3",
|
|
32
|
+
Annotation: "8",
|
|
33
|
+
ToolCall: "9",
|
|
34
|
+
ToolCallResult: "a",
|
|
35
|
+
StartToolCall: "b",
|
|
36
|
+
ToolCallArgsTextDelta: "c",
|
|
37
|
+
FinishMessage: "d",
|
|
38
|
+
FinishStep: "e",
|
|
39
|
+
StartStep: "f",
|
|
40
|
+
ReasoningDelta: "g",
|
|
41
|
+
Source: "h",
|
|
42
|
+
RedactedReasoning: "i",
|
|
43
|
+
ReasoningSignature: "j",
|
|
44
|
+
File: "k",
|
|
45
45
|
|
|
46
|
-
AuiUpdateStateOperations
|
|
47
|
-
AuiTextDelta
|
|
48
|
-
AuiReasoningDelta
|
|
49
|
-
AuiDataPart
|
|
50
|
-
}
|
|
46
|
+
AuiUpdateStateOperations: "aui-state",
|
|
47
|
+
AuiTextDelta: "aui-text-delta",
|
|
48
|
+
AuiReasoningDelta: "aui-reasoning-delta",
|
|
49
|
+
AuiDataPart: "aui-data",
|
|
50
|
+
} as const;
|
|
51
|
+
export type DataStreamStreamChunkType =
|
|
52
|
+
(typeof DataStreamStreamChunkType)[keyof typeof DataStreamStreamChunkType];
|
|
51
53
|
type DataStreamStreamChunkValue = {
|
|
52
54
|
[DataStreamStreamChunkType.TextDelta]: string;
|
|
53
55
|
[DataStreamStreamChunkType.Data]: ReadonlyJSONValue[];
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { LineDecoderStream } from "../../utils/stream/LineDecoderStream";
|
|
3
|
+
import {
|
|
4
|
+
DataStreamChunkDecoder,
|
|
5
|
+
DataStreamChunkEncoder,
|
|
6
|
+
} from "./serialization";
|
|
7
|
+
import { type DataStreamChunk, DataStreamStreamChunkType } from "./chunk-types";
|
|
8
|
+
|
|
9
|
+
async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> {
|
|
10
|
+
const reader = stream.getReader();
|
|
11
|
+
const chunks: T[] = [];
|
|
12
|
+
try {
|
|
13
|
+
while (true) {
|
|
14
|
+
const { done, value } = await reader.read();
|
|
15
|
+
if (done) break;
|
|
16
|
+
chunks.push(value);
|
|
17
|
+
}
|
|
18
|
+
} finally {
|
|
19
|
+
reader.releaseLock();
|
|
20
|
+
}
|
|
21
|
+
return chunks;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createLineStream(lines: string[]): ReadableStream<string> {
|
|
25
|
+
return new ReadableStream<string>({
|
|
26
|
+
start(controller) {
|
|
27
|
+
for (const line of lines) controller.enqueue(line);
|
|
28
|
+
controller.close();
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("DataStreamChunkDecoder", () => {
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("decodes text-delta chunks", async () => {
|
|
39
|
+
const chunks = await collectChunks(
|
|
40
|
+
createLineStream(['0:"hello"', '0:" world"']).pipeThrough(
|
|
41
|
+
new DataStreamChunkDecoder(),
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
expect(chunks).toEqual([
|
|
45
|
+
{ type: DataStreamStreamChunkType.TextDelta, value: "hello" },
|
|
46
|
+
{ type: DataStreamStreamChunkType.TextDelta, value: " world" },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("round-trips through DataStreamChunkEncoder", async () => {
|
|
51
|
+
const values: DataStreamChunk[] = [
|
|
52
|
+
{ type: DataStreamStreamChunkType.TextDelta, value: "hello" },
|
|
53
|
+
{ type: DataStreamStreamChunkType.Error, value: "boom" },
|
|
54
|
+
];
|
|
55
|
+
const source = new ReadableStream<DataStreamChunk>({
|
|
56
|
+
start(controller) {
|
|
57
|
+
for (const v of values) controller.enqueue(v);
|
|
58
|
+
controller.close();
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const decoded = await collectChunks(
|
|
62
|
+
source
|
|
63
|
+
.pipeThrough(new DataStreamChunkEncoder())
|
|
64
|
+
.pipeThrough(new LineDecoderStream())
|
|
65
|
+
.pipeThrough(new DataStreamChunkDecoder()),
|
|
66
|
+
);
|
|
67
|
+
expect(decoded).toEqual(values);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("drops chunks carrying prototype-pollution keys", async () => {
|
|
71
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
72
|
+
const chunks = await collectChunks(
|
|
73
|
+
createLineStream([
|
|
74
|
+
'0:{"__proto__":{"polluted":true}}',
|
|
75
|
+
'0:{"constructor":{"prototype":{"polluted":true}}}',
|
|
76
|
+
'0:"ok"',
|
|
77
|
+
]).pipeThrough(new DataStreamChunkDecoder()),
|
|
78
|
+
);
|
|
79
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
80
|
+
expect(chunks).toEqual([
|
|
81
|
+
{ type: DataStreamStreamChunkType.TextDelta, value: "ok" },
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("drops chunks that are not valid JSON", async () => {
|
|
86
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
87
|
+
const invalid = ["0:{not json", "0:not json"];
|
|
88
|
+
const chunks = await collectChunks(
|
|
89
|
+
createLineStream([...invalid, '0:"ok"']).pipeThrough(
|
|
90
|
+
new DataStreamChunkDecoder(),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
expect(warn.mock.calls.map((c) => c[0])).toEqual(
|
|
94
|
+
invalid.map((l) => `Dropped invalid data-stream chunk: ${l}`),
|
|
95
|
+
);
|
|
96
|
+
expect(chunks).toEqual([
|
|
97
|
+
{ type: DataStreamStreamChunkType.TextDelta, value: "ok" },
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("skips blank and whitespace-only lines silently", async () => {
|
|
102
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
103
|
+
const chunks = await collectChunks(
|
|
104
|
+
createLineStream([
|
|
105
|
+
"",
|
|
106
|
+
'0:"hello"',
|
|
107
|
+
" ",
|
|
108
|
+
"\t",
|
|
109
|
+
'0:" world"',
|
|
110
|
+
]).pipeThrough(new DataStreamChunkDecoder()),
|
|
111
|
+
);
|
|
112
|
+
expect(warn).not.toHaveBeenCalled();
|
|
113
|
+
expect(chunks).toEqual([
|
|
114
|
+
{ type: DataStreamStreamChunkType.TextDelta, value: "hello" },
|
|
115
|
+
{ type: DataStreamStreamChunkType.TextDelta, value: " world" },
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("drops a chunk without a colon separator with a warning", async () => {
|
|
120
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
121
|
+
const chunks = await collectChunks(
|
|
122
|
+
createLineStream(["garbage", '0:"ok"']).pipeThrough(
|
|
123
|
+
new DataStreamChunkDecoder(),
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
expect(warn).toHaveBeenCalledWith(
|
|
127
|
+
"Dropped invalid data-stream chunk: garbage",
|
|
128
|
+
);
|
|
129
|
+
expect(chunks).toEqual([
|
|
130
|
+
{ type: DataStreamStreamChunkType.TextDelta, value: "ok" },
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import sjson from "secure-json-parse";
|
|
1
2
|
import type { DataStreamChunk, DataStreamStreamChunkType } from "./chunk-types";
|
|
2
3
|
|
|
3
4
|
export class DataStreamChunkEncoder extends TransformStream<
|
|
@@ -21,10 +22,27 @@ export class DataStreamChunkDecoder extends TransformStream<
|
|
|
21
22
|
super({
|
|
22
23
|
transform: (chunk, controller) => {
|
|
23
24
|
const index = chunk.indexOf(":");
|
|
24
|
-
if (index === -1)
|
|
25
|
+
if (index === -1) {
|
|
26
|
+
// Blank lines are benign data-stream framing (keepalive newlines,
|
|
27
|
+
// replay-buffer separators emitted on resume/reconnect).
|
|
28
|
+
if (chunk.trim().length === 0) return;
|
|
29
|
+
console.warn(
|
|
30
|
+
`Dropped invalid data-stream chunk: ${chunk.slice(0, 200)}`,
|
|
31
|
+
);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
let value;
|
|
35
|
+
try {
|
|
36
|
+
value = sjson.parse(chunk.slice(index + 1));
|
|
37
|
+
} catch {
|
|
38
|
+
console.warn(
|
|
39
|
+
`Dropped invalid data-stream chunk: ${chunk.slice(0, 200)}`,
|
|
40
|
+
);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
25
43
|
controller.enqueue({
|
|
26
44
|
type: chunk.slice(0, index) as DataStreamStreamChunkType,
|
|
27
|
-
value
|
|
45
|
+
value,
|
|
28
46
|
});
|
|
29
47
|
},
|
|
30
48
|
});
|
|
@@ -604,6 +604,30 @@ describe("UIMessageStreamDecoder", () => {
|
|
|
604
604
|
warn.mockRestore();
|
|
605
605
|
});
|
|
606
606
|
|
|
607
|
+
it("drops frames carrying prototype-pollution keys", async () => {
|
|
608
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
609
|
+
const events = [
|
|
610
|
+
'{"type":"text-delta","delta":"x","__proto__":{"polluted":true}}',
|
|
611
|
+
'{"type":"text-delta","delta":"y","constructor":{"prototype":{"polluted":true}}}',
|
|
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).toHaveBeenCalledTimes(2);
|
|
622
|
+
const deltas = chunks.filter(
|
|
623
|
+
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
|
|
624
|
+
c.type === "text-delta",
|
|
625
|
+
);
|
|
626
|
+
expect(deltas).toHaveLength(1);
|
|
627
|
+
expect(deltas[0]!.textDelta).toBe("ok");
|
|
628
|
+
warn.mockRestore();
|
|
629
|
+
});
|
|
630
|
+
|
|
607
631
|
it("drops frames that are not valid JSON", async () => {
|
|
608
632
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
609
633
|
const invalidFrames = ['{"type":"te', "not json"];
|
|
@@ -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";
|
|
@@ -227,7 +228,7 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
|
|
|
227
228
|
|
|
228
229
|
let chunk;
|
|
229
230
|
try {
|
|
230
|
-
chunk =
|
|
231
|
+
chunk = sjson.parse(event.data);
|
|
231
232
|
} catch {
|
|
232
233
|
chunk = undefined;
|
|
233
234
|
}
|
|
@@ -441,7 +441,11 @@ export class ToolCallArgsReaderImpl<
|
|
|
441
441
|
export class ToolCallResponseReaderImpl<
|
|
442
442
|
TResult extends ReadonlyJSONValue,
|
|
443
443
|
> implements ToolCallResponseReader<TResult> {
|
|
444
|
-
|
|
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;
|
|
@@ -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<
|
|
@@ -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
|
-
|
|
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
|
}
|
package/src/gorp.test.ts
CHANGED
|
@@ -2,16 +2,6 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import * as entry from "./index";
|
|
3
3
|
|
|
4
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
5
|
it("streams operations end to end through the SSE wire", async () => {
|
|
16
6
|
const stream = entry.createObjectStream({
|
|
17
7
|
execute: (controller) => {
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { TimingTracker } from "./TimingTracker";
|
|
3
|
-
|
|
4
|
-
describe("TimingTracker", () => {
|
|
5
|
-
beforeEach(() => {
|
|
6
|
-
vi.useFakeTimers();
|
|
7
|
-
vi.setSystemTime(1000);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
afterEach(() => {
|
|
11
|
-
vi.useRealTimers();
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("should record stream start time", () => {
|
|
15
|
-
const tracker = new TimingTracker();
|
|
16
|
-
const timing = tracker.getTiming();
|
|
17
|
-
expect(timing.streamStartTime).toBe(1000);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("should count chunks", () => {
|
|
21
|
-
const tracker = new TimingTracker();
|
|
22
|
-
tracker.recordChunk();
|
|
23
|
-
tracker.recordChunk();
|
|
24
|
-
tracker.recordChunk();
|
|
25
|
-
const timing = tracker.getTiming();
|
|
26
|
-
expect(timing.totalChunks).toBe(3);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("should record first token time", () => {
|
|
30
|
-
const tracker = new TimingTracker();
|
|
31
|
-
vi.advanceTimersByTime(50);
|
|
32
|
-
tracker.recordFirstToken();
|
|
33
|
-
vi.advanceTimersByTime(100);
|
|
34
|
-
tracker.recordFirstToken(); // should not overwrite
|
|
35
|
-
const timing = tracker.getTiming();
|
|
36
|
-
expect(timing.firstTokenTime).toBe(50);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("should not set firstTokenTime if no text tokens", () => {
|
|
40
|
-
const tracker = new TimingTracker();
|
|
41
|
-
const timing = tracker.getTiming();
|
|
42
|
-
expect(timing.firstTokenTime).toBeUndefined();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("should track unique tool calls", () => {
|
|
46
|
-
const tracker = new TimingTracker();
|
|
47
|
-
tracker.recordToolCallStart("tc-1");
|
|
48
|
-
tracker.recordToolCallStart("tc-2");
|
|
49
|
-
tracker.recordToolCallStart("tc-1"); // duplicate
|
|
50
|
-
const timing = tracker.getTiming();
|
|
51
|
-
expect(timing.toolCallCount).toBe(2);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("should compute totalStreamTime", () => {
|
|
55
|
-
const tracker = new TimingTracker();
|
|
56
|
-
vi.advanceTimersByTime(200);
|
|
57
|
-
const timing = tracker.getTiming();
|
|
58
|
-
expect(timing.totalStreamTime).toBe(200);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("should use outputTokens when available", () => {
|
|
62
|
-
const tracker = new TimingTracker();
|
|
63
|
-
vi.advanceTimersByTime(1000);
|
|
64
|
-
const timing = tracker.getTiming(42, "some text");
|
|
65
|
-
expect(timing.tokenCount).toBe(42);
|
|
66
|
-
expect(timing.tokensPerSecond).toBe(42);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("should estimate tokens from text when no outputTokens", () => {
|
|
70
|
-
const tracker = new TimingTracker();
|
|
71
|
-
vi.advanceTimersByTime(1000);
|
|
72
|
-
// 20 chars / 4 = 5 tokens
|
|
73
|
-
const timing = tracker.getTiming(undefined, "12345678901234567890");
|
|
74
|
-
expect(timing.tokenCount).toBe(5);
|
|
75
|
-
expect(timing.tokensPerSecond).toBe(5);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("should handle zero outputTokens by falling back to text", () => {
|
|
79
|
-
const tracker = new TimingTracker();
|
|
80
|
-
vi.advanceTimersByTime(1000);
|
|
81
|
-
const timing = tracker.getTiming(0, "1234567890"); // 10 chars -> 3 tokens
|
|
82
|
-
expect(timing.tokenCount).toBe(3);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("should handle no tokens and no text", () => {
|
|
86
|
-
const tracker = new TimingTracker();
|
|
87
|
-
vi.advanceTimersByTime(100);
|
|
88
|
-
const timing = tracker.getTiming();
|
|
89
|
-
expect(timing.tokenCount).toBeUndefined();
|
|
90
|
-
expect(timing.tokensPerSecond).toBeUndefined();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("should handle zero stream time (no division by zero)", () => {
|
|
94
|
-
const tracker = new TimingTracker();
|
|
95
|
-
// Don't advance time — totalStreamTime = 0
|
|
96
|
-
const timing = tracker.getTiming(100, "text");
|
|
97
|
-
expect(timing.totalStreamTime).toBe(0);
|
|
98
|
-
expect(timing.tokensPerSecond).toBeUndefined();
|
|
99
|
-
});
|
|
100
|
-
});
|