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.
Files changed (48) hide show
  1. package/dist/core/accumulators/AssistantMessageStream.d.ts.map +1 -1
  2. package/dist/core/accumulators/AssistantMessageStream.js +0 -1
  3. package/dist/core/accumulators/AssistantMessageStream.js.map +1 -1
  4. package/dist/core/modules/tool-call.d.ts.map +1 -1
  5. package/dist/core/modules/tool-call.js +1 -1
  6. package/dist/core/modules/tool-call.js.map +1 -1
  7. package/dist/core/serialization/PlainText.d.ts.map +1 -1
  8. package/dist/core/serialization/PlainText.js +4 -18
  9. package/dist/core/serialization/PlainText.js.map +1 -1
  10. package/dist/core/serialization/data-stream/DataStream.js +48 -48
  11. package/dist/core/serialization/data-stream/DataStream.js.map +1 -1
  12. package/dist/core/serialization/data-stream/chunk-types.d.ts +23 -22
  13. package/dist/core/serialization/data-stream/chunk-types.d.ts.map +1 -1
  14. package/dist/core/serialization/data-stream/chunk-types.js +22 -23
  15. package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
  16. package/dist/core/serialization/data-stream/serialization.d.ts.map +1 -1
  17. package/dist/core/serialization/data-stream/serialization.js +14 -2
  18. package/dist/core/serialization/data-stream/serialization.js.map +1 -1
  19. package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
  20. package/dist/core/serialization/ui-message-stream/UIMessageStream.js +2 -1
  21. package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
  22. package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
  23. package/dist/core/tool/ToolCallReader.js.map +1 -1
  24. package/dist/core/tool/tool-types.d.ts +6 -0
  25. package/dist/core/tool/tool-types.d.ts.map +1 -1
  26. package/dist/core/utils/stream/SSE.d.ts.map +1 -1
  27. package/dist/core/utils/stream/SSE.js +11 -2
  28. package/dist/core/utils/stream/SSE.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/core/accumulators/AssistantMessageStream.ts +3 -1
  31. package/src/core/converters/toGenericMessages.test.ts +3 -39
  32. package/src/core/gorp/GorpStreamDeltaTracker.test.ts +30 -0
  33. package/src/core/modules/tool-call.ts +4 -1
  34. package/src/core/serialization/PlainText.test.ts +54 -0
  35. package/src/core/serialization/PlainText.ts +8 -27
  36. package/src/core/serialization/data-stream/DataStream.ts +1 -1
  37. package/src/core/serialization/data-stream/chunk-types.ts +24 -22
  38. package/src/core/serialization/data-stream/serialization.test.ts +133 -0
  39. package/src/core/serialization/data-stream/serialization.ts +20 -2
  40. package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +24 -0
  41. package/src/core/serialization/ui-message-stream/UIMessageStream.ts +2 -1
  42. package/src/core/tool/ToolCallReader.ts +5 -1
  43. package/src/core/tool/tool-types.ts +7 -0
  44. package/src/core/utils/stream/SSE.test.ts +97 -0
  45. package/src/core/utils/stream/SSE.ts +13 -2
  46. package/src/gorp.test.ts +0 -10
  47. package/src/core/accumulators/TimingTracker.test.ts +0 -100
  48. 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<AssistantStreamChunk, string>({
20
+ const transform = new TransformStream<AssistantMetaStreamChunk, string>({
18
21
  transform(chunk, controller) {
19
- const type = chunk.type;
20
- switch (type) {
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 enum 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",
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 = "aui-state",
47
- AuiTextDelta = "aui-text-delta",
48
- AuiReasoningDelta = "aui-reasoning-delta",
49
- AuiDataPart = "aui-data",
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) throw new Error("Invalid stream part");
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: JSON.parse(chunk.slice(index + 1)),
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 = JSON.parse(event.data);
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
- constructor(private readonly promise: Promise<ToolResponse<TResult>>) {}
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
- controller.enqueue(JSON.parse(event.data));
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
- });