assistant-stream 0.3.27 → 0.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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
|
@@ -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
|
-
});
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
diffKeys,
|
|
4
|
-
lookupChange,
|
|
5
|
-
markChanged,
|
|
6
|
-
mergeChanged,
|
|
7
|
-
type ChangeNode,
|
|
8
|
-
} from "./changeTree";
|
|
9
|
-
|
|
10
|
-
describe("markChanged", () => {
|
|
11
|
-
it("collapses to true when path is empty", () => {
|
|
12
|
-
expect(markChanged({}, [])).toBe(true);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("keeps `true` after attempting to descend into it", () => {
|
|
16
|
-
const node: ChangeNode = { a: true };
|
|
17
|
-
const result = markChanged(node, ["a", "b"]);
|
|
18
|
-
expect(result).toBe(node);
|
|
19
|
-
expect(node.a).toBe(true);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("overwrites a nested subtree with `true` when the path collapses to the parent", () => {
|
|
23
|
-
const node: ChangeNode = { a: { b: true } };
|
|
24
|
-
markChanged(node, ["a"]);
|
|
25
|
-
expect(node.a).toBe(true);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("creates intermediate nodes lazily without disturbing siblings", () => {
|
|
29
|
-
const node: ChangeNode = { a: { existing: true } };
|
|
30
|
-
markChanged(node, ["a", "b", "c"]);
|
|
31
|
-
expect(node).toEqual({
|
|
32
|
-
a: { existing: true, b: { c: true } },
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("is idempotent when the same leaf is marked twice", () => {
|
|
37
|
-
const node: ChangeNode = {};
|
|
38
|
-
markChanged(node, ["a", "b"]);
|
|
39
|
-
markChanged(node, ["a", "b"]);
|
|
40
|
-
expect(node).toEqual({ a: { b: true } });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("rejects unsafe path segments", () => {
|
|
44
|
-
const node: ChangeNode = {};
|
|
45
|
-
expect(() => markChanged(node, ["__proto__", "polluted"])).toThrow(
|
|
46
|
-
/Unsafe gorp path segment/,
|
|
47
|
-
);
|
|
48
|
-
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe("mergeChanged", () => {
|
|
53
|
-
it("returns true when either side is true", () => {
|
|
54
|
-
expect(mergeChanged(true, {})).toBe(true);
|
|
55
|
-
expect(mergeChanged({}, true)).toBe(true);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("merges disjoint subtrees in place", () => {
|
|
59
|
-
const target: ChangeNode = { a: true };
|
|
60
|
-
const source: ChangeNode = { b: true };
|
|
61
|
-
const result = mergeChanged(target, source);
|
|
62
|
-
expect(result).toBe(target);
|
|
63
|
-
expect(target).toEqual({ a: true, b: true });
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("clones source subtrees before assigning them", () => {
|
|
67
|
-
const target: ChangeNode = {};
|
|
68
|
-
const source: ChangeNode = { a: { b: true } };
|
|
69
|
-
mergeChanged(target, source);
|
|
70
|
-
(source as { a: { c?: true } }).a.c = true;
|
|
71
|
-
expect(target).toEqual({ a: { b: true } });
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("deep-merges overlapping subtrees", () => {
|
|
75
|
-
const target: ChangeNode = { a: { x: true } };
|
|
76
|
-
const source: ChangeNode = { a: { y: true } };
|
|
77
|
-
mergeChanged(target, source);
|
|
78
|
-
expect(target).toEqual({ a: { x: true, y: true } });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("propagates `true` upward when one side has it at a shared key", () => {
|
|
82
|
-
const target: ChangeNode = { a: { x: true } };
|
|
83
|
-
const source: ChangeNode = { a: true };
|
|
84
|
-
mergeChanged(target, source);
|
|
85
|
-
expect(target).toEqual({ a: true });
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("rejects unsafe source keys", () => {
|
|
89
|
-
const target: ChangeNode = {};
|
|
90
|
-
const source = JSON.parse('{"__proto__":true}') as ChangeNode;
|
|
91
|
-
expect(() => mergeChanged(target, source)).toThrow(
|
|
92
|
-
/Unsafe gorp path segment/,
|
|
93
|
-
);
|
|
94
|
-
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("rejects nested unsafe source keys before assigning a subtree", () => {
|
|
98
|
-
const target: ChangeNode = {};
|
|
99
|
-
const source = JSON.parse('{"a":{"constructor":true}}') as ChangeNode;
|
|
100
|
-
expect(() => mergeChanged(target, source)).toThrow(
|
|
101
|
-
/Unsafe gorp path segment/,
|
|
102
|
-
);
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
describe("lookupChange", () => {
|
|
107
|
-
it("returns false for paths that don't exist", () => {
|
|
108
|
-
expect(lookupChange({ a: true }, ["b"])).toBe(false);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("returns true when traversal hits a `true` sentinel", () => {
|
|
112
|
-
expect(lookupChange({ a: true }, ["a", "b", "c"])).toBe(true);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("returns the subtree when the path lands on an interior node", () => {
|
|
116
|
-
const node: ChangeNode = { a: { b: true } };
|
|
117
|
-
const result = lookupChange(node, ["a"]);
|
|
118
|
-
expect(result).toEqual({ b: true });
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("returns the root node for the empty path", () => {
|
|
122
|
-
const node: ChangeNode = { a: true };
|
|
123
|
-
expect(lookupChange(node, [])).toBe(node);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("returns false for inherited object members", () => {
|
|
127
|
-
expect(lookupChange({}, ["toString"])).toBe(false);
|
|
128
|
-
expect(lookupChange({ a: {} }, ["a", "hasOwnProperty"])).toBe(false);
|
|
129
|
-
expect(lookupChange({ a: true }, ["constructor"])).toBe(false);
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
describe("inherited object members", () => {
|
|
134
|
-
it("markChanged does not descend into inherited members", () => {
|
|
135
|
-
const node: ChangeNode = {};
|
|
136
|
-
markChanged(node, ["toString", "x"]);
|
|
137
|
-
expect(node).toEqual({ toString: { x: true } });
|
|
138
|
-
expect(lookupChange(node, ["toString", "x"])).toBe(true);
|
|
139
|
-
expect(lookupChange(node, ["valueOf"])).toBe(false);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("mergeChanged ignores inherited target members", () => {
|
|
143
|
-
const target: ChangeNode = {};
|
|
144
|
-
const source: ChangeNode = { toString: { x: true } };
|
|
145
|
-
mergeChanged(target, source);
|
|
146
|
-
expect(target).toEqual({ toString: { x: true } });
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
describe("diffKeys", () => {
|
|
151
|
-
it("returns new keys in order, then deleted keys in reverse", () => {
|
|
152
|
-
expect(diffKeys({ b: 1, c: 2 }, { a: 1, b: 2, d: 3 })).toEqual([
|
|
153
|
-
"b",
|
|
154
|
-
"c",
|
|
155
|
-
"d",
|
|
156
|
-
"a",
|
|
157
|
-
]);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("returns new keys when the old value is not an object", () => {
|
|
161
|
-
expect(diffKeys({ a: 1 }, null)).toEqual(["a"]);
|
|
162
|
-
expect(diffKeys({ a: 1 }, "str")).toEqual(["a"]);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("returns deleted keys when the new value is not an object", () => {
|
|
166
|
-
expect(diffKeys(null, { a: 1, b: 2 })).toEqual(["b", "a"]);
|
|
167
|
-
});
|
|
168
|
-
});
|