convex-durable-agents 0.2.4 → 0.2.6

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 (47) hide show
  1. package/README.md +1 -0
  2. package/dist/client/handler.d.ts +4 -0
  3. package/dist/client/handler.d.ts.map +1 -1
  4. package/dist/client/handler.js +25 -3
  5. package/dist/client/handler.js.map +1 -1
  6. package/dist/client/streamer.d.ts +3 -1
  7. package/dist/client/streamer.d.ts.map +1 -1
  8. package/dist/client/streamer.js +9 -3
  9. package/dist/client/streamer.js.map +1 -1
  10. package/dist/component/tool_calls.d.ts +1 -0
  11. package/dist/component/tool_calls.d.ts.map +1 -1
  12. package/dist/component/tool_calls.js +7 -0
  13. package/dist/component/tool_calls.js.map +1 -1
  14. package/dist/react/test/happy-dom-setup.d.ts +2 -0
  15. package/dist/react/test/happy-dom-setup.d.ts.map +1 -0
  16. package/dist/react/test/happy-dom-setup.js +28 -0
  17. package/dist/react/test/happy-dom-setup.js.map +1 -0
  18. package/dist/utils/msg.d.ts +3 -0
  19. package/dist/utils/msg.d.ts.map +1 -0
  20. package/dist/utils/msg.js +7 -0
  21. package/dist/utils/msg.js.map +1 -0
  22. package/package.json +24 -21
  23. package/src/client/handler.ts +33 -2
  24. package/src/client/streamer.test.ts +187 -0
  25. package/src/client/streamer.ts +10 -3
  26. package/src/client/tools.test.ts +48 -0
  27. package/src/component/messages.test.ts +40 -0
  28. package/src/component/streams.test.ts +118 -0
  29. package/src/component/threads.test.ts +48 -0
  30. package/src/component/tool_calls.ts +9 -0
  31. package/src/react/__fixtures__/01-early-streaming-start.json +35 -0
  32. package/src/react/__fixtures__/02-reasoning-complete-tool-call.json +85 -0
  33. package/src/react/__fixtures__/03-new-round-seq2.json +89 -0
  34. package/src/react/__fixtures__/04-tool-call-error-seq3.json +145 -0
  35. package/src/react/__fixtures__/05-later-round-seq5.json +117 -0
  36. package/src/react/__fixtures__/06-text-streaming-seq6.json +162 -0
  37. package/src/react/__fixtures__/07-text-streaming-more-seq6.json +212 -0
  38. package/src/react/__fixtures__/08-fully-committed-seq6.json +188 -0
  39. package/src/react/__snapshots__/apply-streaming-updates.test.ts.snap +1357 -0
  40. package/src/react/__snapshots__/use-thread-messages.test.tsx.snap +1429 -0
  41. package/src/react/agent-chat.test.tsx +155 -0
  42. package/src/react/apply-streaming-updates.test.ts +28 -0
  43. package/src/react/test/happy-dom-setup.ts +31 -0
  44. package/src/react/use-thread-messages.test.tsx +702 -0
  45. package/src/utils/msg.test.ts +34 -0
  46. package/src/utils/msg.ts +8 -0
  47. package/src/utils/retry.test.ts +214 -0
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "bugs": {
7
7
  "url": "https://github.com/ziegfried/convex-durable-agents/issues"
8
8
  },
9
- "version": "0.2.4",
9
+ "version": "0.2.6",
10
10
  "license": "Apache-2.0",
11
11
  "keywords": [
12
12
  "convex",
@@ -83,38 +83,41 @@
83
83
  }
84
84
  },
85
85
  "devDependencies": {
86
- "@biomejs/biome": "^2.3.14",
87
- "@convex-dev/eslint-plugin": "^1.1.1",
88
- "@convex-dev/workpool": "^0.3.1",
86
+ "@biomejs/biome": "^2.4.14",
87
+ "@convex-dev/eslint-plugin": "^2.0.0",
88
+ "@convex-dev/workpool": "^0.4.6",
89
89
  "@edge-runtime/vm": "^5.0.0",
90
- "@eslint/eslintrc": "^3.3.1",
90
+ "@eslint/eslintrc": "^3.3.5",
91
91
  "@eslint/js": "9.39.2",
92
- "@types/bun": "^1.3.8",
93
- "@types/node": "^24.10.11",
94
- "@types/react": "^19.2.13",
92
+ "@testing-library/react": "^16.3.2",
93
+ "@types/bun": "^1.3.13",
94
+ "@types/node": "^24.12.2",
95
+ "@types/react": "^19.2.14",
95
96
  "@types/react-dom": "^19.2.3",
96
- "@vitejs/plugin-react": "^5.1.3",
97
- "ai": "^6.0.75",
97
+ "@vitejs/plugin-react": "^6.0.1",
98
+ "ai": "^6.0.174",
98
99
  "chokidar-cli": "3.0.0",
99
- "convex": "1.31.7",
100
- "convex-test": "0.0.41",
100
+ "convex": "1.37.0",
101
+ "convex-test": "0.0.51",
101
102
  "cpy-cli": "^7.0.0",
102
103
  "eslint": "9.39.2",
103
104
  "eslint-plugin-react": "^7.37.5",
104
- "eslint-plugin-react-hooks": "^7.0.1",
105
- "eslint-plugin-react-refresh": "^0.5.0",
106
- "globals": "^17.3.0",
105
+ "eslint-plugin-react-hooks": "^7.1.1",
106
+ "eslint-plugin-react-refresh": "^0.5.2",
107
+ "globals": "^17.6.0",
108
+ "happy-dom": "^20.9.0",
107
109
  "npm-run-all2": "8.0.4",
108
110
  "path-exists-cli": "2.0.0",
109
111
  "pkg-pr-new": "^0.0.63",
110
- "react": "^19.2.4",
111
- "react-dom": "^19.2.4",
112
+ "react": "^19.2.5",
113
+ "react-dom": "^19.2.5",
112
114
  "typescript": "5.9.3",
113
- "typescript-eslint": "8.54.0",
114
- "vite": "7.3.1",
115
+ "typescript-eslint": "8.56.1",
116
+ "vite": "8.0.10",
115
117
  "vitest": "4.0.18",
116
- "zod": "^4.3.6"
118
+ "zod": "^4.4.2"
117
119
  },
118
120
  "types": "./dist/client/index.d.ts",
119
- "module": "./dist/client/index.js"
121
+ "module": "./dist/client/index.js",
122
+ "packageManager": "bun@1.3.9"
120
123
  }
@@ -13,6 +13,7 @@ import { v } from "convex/values";
13
13
  import type { ComponentApi } from "../component/_generated/component.js";
14
14
  import type { Id } from "../component/_generated/dataModel.js";
15
15
  import { Logger } from "../utils/logger.js";
16
+ import { endsWithAssistantMessage } from "../utils/msg.js";
16
17
  import {
17
18
  clampDelayMs,
18
19
  classifyRetryErrorDefault,
@@ -38,6 +39,8 @@ import { getStreamTextProviderMetadata, getStreamTextUsage, type UsageInfo } fro
38
39
  export type StreamingOptions = {
39
40
  throttleMs?: number;
40
41
  returnImmediately?: boolean;
42
+ /** Include tool-input-delta chunks in the stream, enabling incremental tool call input on the client. Default: false */
43
+ includeToolInputDeltas?: boolean;
41
44
  };
42
45
 
43
46
  const DEFAULT_STREAMING_OPTIONS: StreamingOptions = {
@@ -92,6 +95,8 @@ export type ErrorHandlerCallback = (ctx: ActionCtx, args: ErrorHandlerArgs) => v
92
95
 
93
96
  export type StreamHandlerArgs = Omit<Parameters<typeof streamText>[0], "tools" | "messages" | "prompt"> & {
94
97
  tools: Record<string, DurableTool<unknown, unknown>>;
98
+ /** Optional: provider-native tools that should be passed through directly to streamText */
99
+ providerTools?: NonNullable<Parameters<typeof streamText>[0]["tools"]>;
95
100
  /** Optional: Save streaming deltas to the database for real-time client updates */
96
101
  saveStreamDeltas?: boolean | StreamingOptions;
97
102
  /** Optional: Transform the messages before sending them to the model */
@@ -195,6 +200,7 @@ export function streamHandlerAction(
195
200
  typeof argsOrFactory === "function" ? await argsOrFactory(ctx as ActionCtx, args.threadId) : argsOrFactory;
196
201
  const {
197
202
  tools,
203
+ providerTools,
198
204
  saveStreamDeltas,
199
205
  transformMessages = (messages) => messages,
200
206
  onMessageComplete: usageHandlerCallback,
@@ -214,6 +220,7 @@ export function streamHandlerAction(
214
220
  threadId: args.threadId as Id<"threads">,
215
221
  streamId: args.streamId as Id<"streams">,
216
222
  lockId,
223
+ includeToolInputDeltas: streamingOptions.includeToolInputDeltas ?? false,
217
224
  });
218
225
  logger.debug("Acquiring stream lock...");
219
226
  const stream = await streamer.acquireLock().catch((e) => {
@@ -249,13 +256,19 @@ export function streamHandlerAction(
249
256
 
250
257
  // Build tool definitions for AI SDK (without execute functions)
251
258
  const handlerlessTools: Record<string, Tool> = {};
259
+ const durableToolNames = new Set<string>();
252
260
  for (const toolDef of toolDefinitions) {
261
+ durableToolNames.add(toolDef.name);
253
262
  handlerlessTools[toolDef.name] = tool({
254
263
  description: toolDef.description,
255
264
  inputSchema: jsonSchema(toolDef.parameters as Parameters<typeof jsonSchema>[0]),
256
265
  // No execute function - we handle tool calls manually
257
266
  });
258
267
  }
268
+ const modelTools = {
269
+ ...handlerlessTools,
270
+ ...(providerTools ?? {}),
271
+ };
259
272
 
260
273
  const thread = await ctx.runQuery(component.threads.get, {
261
274
  threadId: args.threadId as Id<"threads">,
@@ -275,7 +288,19 @@ export function streamHandlerAction(
275
288
 
276
289
  const uiMessages = messages.map((m) => messageDocToUIMessage(m));
277
290
  logger.debug(`Converted ${uiMessages.length} UI messages, transforming to model messages...`);
278
- const modelMessages = transformMessages(await convertToModelMessages(uiMessages, { tools: handlerlessTools }));
291
+ const modelMessages = transformMessages(await convertToModelMessages(uiMessages, { tools: modelTools }));
292
+ if (endsWithAssistantMessage(modelMessages)) {
293
+ logger.warn(
294
+ "Skipping streamText because transformed messages end with an assistant turn; no user/tool turn available",
295
+ );
296
+ finalStatus = "completed";
297
+ await streamer.finish();
298
+ await ctx.runMutation(component.threads.clearRetryState, {
299
+ threadId: args.threadId as Id<"threads">,
300
+ });
301
+ logger.debug("Stream handler completed without generation due to trailing assistant message");
302
+ return null;
303
+ }
279
304
  logger.debug(`Model messages ready (${modelMessages.length} messages), starting streamText...`);
280
305
 
281
306
  let toolCallCount = 0;
@@ -285,7 +310,7 @@ export function streamHandlerAction(
285
310
  ...streamTextArgs,
286
311
  prompt: undefined,
287
312
  messages: modelMessages,
288
- tools: handlerlessTools,
313
+ tools: modelTools,
289
314
  });
290
315
 
291
316
  let finishReason: string | undefined;
@@ -328,6 +353,12 @@ export function streamHandlerAction(
328
353
  logger.debug(
329
354
  `Stream part: tool-input-available (tool=${part.toolName}, callId=${part.toolCallId}, count=${toolCallCount})`,
330
355
  );
356
+ if (!durableToolNames.has(part.toolName)) {
357
+ logger.debug(
358
+ `Skipping scheduling for provider tool call: ${part.toolName} (callId=${part.toolCallId})`,
359
+ );
360
+ break;
361
+ }
331
362
  await scheduleToolCall(
332
363
  ctx,
333
364
  {
@@ -0,0 +1,187 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { UIMessageChunk } from "ai";
3
+ import { joinAdjacentDeltas, Streamer } from "./streamer.js";
4
+
5
+ /**
6
+ * Minimal mocks for testing the Streamer in isolation.
7
+ * We only need the mutation calls the Streamer makes.
8
+ */
9
+ function createMockCtx() {
10
+ return {
11
+ runQuery: vi.fn().mockResolvedValue(null),
12
+ runMutation: vi.fn().mockResolvedValue(null),
13
+ runAction: vi.fn().mockResolvedValue(null),
14
+ storage: {} as any,
15
+ auth: {} as any,
16
+ scheduler: { runAfter: vi.fn() } as any,
17
+ };
18
+ }
19
+
20
+ function createMockComponent() {
21
+ return {
22
+ streams: {
23
+ take: "streams:take" as any,
24
+ addDelta: "streams:addDelta" as any,
25
+ finish: "streams:finish" as any,
26
+ abort: "streams:abort" as any,
27
+ heartbeat: "streams:heartbeat" as any,
28
+ },
29
+ } as any;
30
+ }
31
+
32
+ function createStreamer(ctx = createMockCtx(), component = createMockComponent()) {
33
+ const streamer = new Streamer(component, ctx, {
34
+ throttleMs: 50,
35
+ heartbeatMs: 60_000,
36
+ lockId: "test-lock",
37
+ threadId: "test-thread" as any,
38
+ streamId: "test-stream" as any,
39
+ includeToolInputDeltas: false,
40
+ });
41
+ return { streamer, ctx, component };
42
+ }
43
+
44
+ describe("Streamer", () => {
45
+ describe("fail() cancels pending flush timeout", () => {
46
+ it("should not write deltas after fail() is called", async () => {
47
+ const { streamer, ctx } = createStreamer();
48
+ streamer.enableDeltaStreaming();
49
+ await streamer.setMessageId("msg-1", false);
50
+
51
+ // Queue some parts via process() — this schedules a throttled flush
52
+ await streamer.process({ type: "text-delta" as any, id: "t1", delta: "hello " });
53
+ await streamer.process({ type: "text-delta" as any, id: "t1", delta: "world" });
54
+
55
+ // Now call fail() — this should cancel the pending flush
56
+ await streamer.fail("Provider connection lost");
57
+
58
+ // Verify abort was called
59
+ expect(ctx.runMutation).toHaveBeenCalledWith("streams:abort", {
60
+ streamId: "test-stream",
61
+ reason: "Provider connection lost",
62
+ });
63
+
64
+ // Wait longer than the throttle interval to give the (cancelled) timeout
65
+ // a chance to fire if it wasn't properly cancelled
66
+ await new Promise((resolve) => setTimeout(resolve, 150));
67
+
68
+ // addDelta should NEVER have been called — the flush was cancelled
69
+ const addDeltaCalls = ctx.runMutation.mock.calls.filter(
70
+ (call) => call[0] === "streams:addDelta",
71
+ );
72
+ expect(addDeltaCalls).toHaveLength(0);
73
+ });
74
+
75
+ it("should still flush deltas on finish() (happy path)", async () => {
76
+ const { streamer, ctx } = createStreamer();
77
+ streamer.enableDeltaStreaming();
78
+ await streamer.setMessageId("msg-1", false);
79
+
80
+ await streamer.process({ type: "text-delta" as any, id: "t1", delta: "hello" });
81
+
82
+ await streamer.finish();
83
+
84
+ // finish() should flush remaining deltas then mark stream finished
85
+ const addDeltaCalls = ctx.runMutation.mock.calls.filter(
86
+ (call) => call[0] === "streams:addDelta",
87
+ );
88
+ expect(addDeltaCalls).toHaveLength(1);
89
+
90
+ expect(ctx.runMutation).toHaveBeenCalledWith("streams:finish", {
91
+ streamId: "test-stream",
92
+ });
93
+ });
94
+ });
95
+ });
96
+
97
+ describe("joinAdjacentDeltas", () => {
98
+ it("returns empty array for empty input", () => {
99
+ expect(joinAdjacentDeltas([])).toEqual([]);
100
+ });
101
+
102
+ it("joins adjacent text-delta chunks with the same id", () => {
103
+ const chunks: Array<UIMessageChunk> = [
104
+ { type: "text-delta", id: "a", delta: "Hello" },
105
+ { type: "text-delta", id: "a", delta: " world" },
106
+ { type: "text-delta", id: "a", delta: "!" },
107
+ ];
108
+ expect(joinAdjacentDeltas(chunks)).toEqual([{ type: "text-delta", id: "a", delta: "Hello world!" }]);
109
+ });
110
+
111
+ it("joins adjacent reasoning-delta chunks with the same id", () => {
112
+ const chunks: Array<UIMessageChunk> = [
113
+ { type: "reasoning-delta", id: "r1", delta: "Let me " },
114
+ { type: "reasoning-delta", id: "r1", delta: "think" },
115
+ ];
116
+ expect(joinAdjacentDeltas(chunks)).toEqual([{ type: "reasoning-delta", id: "r1", delta: "Let me think" }]);
117
+ });
118
+
119
+ it("does not join text-delta chunks with different ids", () => {
120
+ const chunks: Array<UIMessageChunk> = [
121
+ { type: "text-delta", id: "a", delta: "Hello" },
122
+ { type: "text-delta", id: "b", delta: " world" },
123
+ ];
124
+ expect(joinAdjacentDeltas(chunks)).toEqual([
125
+ { type: "text-delta", id: "a", delta: "Hello" },
126
+ { type: "text-delta", id: "b", delta: " world" },
127
+ ]);
128
+ });
129
+
130
+ it("does not join text-delta and reasoning-delta even with same id", () => {
131
+ const chunks: Array<UIMessageChunk> = [
132
+ { type: "text-delta", id: "a", delta: "Hello" },
133
+ { type: "reasoning-delta", id: "a", delta: "think" },
134
+ ];
135
+ expect(joinAdjacentDeltas(chunks)).toEqual([
136
+ { type: "text-delta", id: "a", delta: "Hello" },
137
+ { type: "reasoning-delta", id: "a", delta: "think" },
138
+ ]);
139
+ });
140
+
141
+ it("does not join non-adjacent same-type chunks", () => {
142
+ const chunks: Array<UIMessageChunk> = [
143
+ { type: "text-delta", id: "a", delta: "Hello" },
144
+ { type: "text-start", id: "b" },
145
+ { type: "text-delta", id: "a", delta: " world" },
146
+ ];
147
+ expect(joinAdjacentDeltas(chunks)).toEqual([
148
+ { type: "text-delta", id: "a", delta: "Hello" },
149
+ { type: "text-start", id: "b" },
150
+ { type: "text-delta", id: "a", delta: " world" },
151
+ ]);
152
+ });
153
+
154
+ it("passes through non-delta chunk types unchanged", () => {
155
+ const chunks: Array<UIMessageChunk> = [
156
+ { type: "text-start", id: "a" },
157
+ { type: "text-end", id: "a" },
158
+ { type: "start" },
159
+ { type: "finish" },
160
+ ];
161
+ expect(joinAdjacentDeltas(chunks)).toEqual(chunks);
162
+ });
163
+
164
+ it("handles mixed delta and non-delta chunks", () => {
165
+ const chunks: Array<UIMessageChunk> = [
166
+ { type: "text-start", id: "a" },
167
+ { type: "text-delta", id: "a", delta: "Hello" },
168
+ { type: "text-delta", id: "a", delta: " world" },
169
+ { type: "text-end", id: "a" },
170
+ { type: "reasoning-start", id: "r1" },
171
+ { type: "reasoning-delta", id: "r1", delta: "Step " },
172
+ { type: "reasoning-delta", id: "r1", delta: "1" },
173
+ ];
174
+ expect(joinAdjacentDeltas(chunks)).toEqual([
175
+ { type: "text-start", id: "a" },
176
+ { type: "text-delta", id: "a", delta: "Hello world" },
177
+ { type: "text-end", id: "a" },
178
+ { type: "reasoning-start", id: "r1" },
179
+ { type: "reasoning-delta", id: "r1", delta: "Step 1" },
180
+ ]);
181
+ });
182
+
183
+ it("handles a single chunk", () => {
184
+ const chunks: Array<UIMessageChunk> = [{ type: "text-delta", id: "a", delta: "Hello" }];
185
+ expect(joinAdjacentDeltas(chunks)).toEqual([{ type: "text-delta", id: "a", delta: "Hello" }]);
186
+ });
187
+ });
@@ -24,6 +24,7 @@ export class Streamer {
24
24
  lockId: string;
25
25
  threadId: Id<"threads">;
26
26
  streamId: Id<"streams">;
27
+ includeToolInputDeltas: boolean;
27
28
  },
28
29
  ) {
29
30
  this.#logger = new Logger(`streamer:${config.streamId}`);
@@ -129,7 +130,7 @@ export class Streamer {
129
130
  try {
130
131
  const queue = this.#queue;
131
132
  this.#queue = [];
132
- const compacted = compactQueue(queue);
133
+ const compacted = compactQueue(queue, this.config.includeToolInputDeltas);
133
134
  if (compacted.length === 0) {
134
135
  this.#logger.debug(`Skipping delta write: seq=${this.#seq}, rawParts=${queue.length}, compactedParts=0`);
135
136
  return;
@@ -169,6 +170,11 @@ export class Streamer {
169
170
 
170
171
  async fail(reason: string): Promise<void> {
171
172
  try {
173
+ if (this.#flushTimeout != null) {
174
+ clearTimeout(this.#flushTimeout);
175
+ this.#flushTimeout = undefined;
176
+ }
177
+ this.#queue = [];
172
178
  this.#logger.debug(`Aborting stream: ${reason}`);
173
179
  await this.ctx.runMutation(this.component.streams.abort, {
174
180
  streamId: this.config.streamId,
@@ -181,8 +187,9 @@ export class Streamer {
181
187
  }
182
188
  }
183
189
 
184
- export function compactQueue(queue: Array<UIMessageChunk>): Array<UIMessageChunk> {
185
- return joinAdjacentDeltas(queue.filter((part) => !(part.type === "tool-input-delta")).map(dropUnnecessaryInfo));
190
+ export function compactQueue(queue: Array<UIMessageChunk>, includeToolInputDeltas = false): Array<UIMessageChunk> {
191
+ const filtered = includeToolInputDeltas ? queue : queue.filter((part) => part.type !== "tool-input-delta");
192
+ return joinAdjacentDeltas(filtered.map(dropUnnecessaryInfo));
186
193
  }
187
194
 
188
195
  export function dropUnnecessaryInfo(chunk: UIMessageChunk): UIMessageChunk {
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { FunctionReference } from "convex/server";
3
+ import { z } from "zod";
4
+ import { createActionTool } from "./tools";
5
+
6
+ const fakeHandler = "internal:fake.handler" as unknown as FunctionReference<"action", "internal" | "public">;
7
+
8
+ describe("createActionTool", () => {
9
+ it("normalizes retry=true into enabled retry policy", () => {
10
+ const tool = createActionTool({
11
+ description: "test",
12
+ args: z.object({ value: z.string() }),
13
+ handler: fakeHandler,
14
+ retry: true,
15
+ });
16
+
17
+ expect(tool.retry).toEqual({ enabled: true });
18
+ });
19
+
20
+ it("preserves custom retry policy", () => {
21
+ const tool = createActionTool({
22
+ description: "test",
23
+ args: z.object({ value: z.string() }),
24
+ handler: fakeHandler,
25
+ retry: {
26
+ enabled: true,
27
+ maxAttempts: 5,
28
+ backoff: { strategy: "fixed", delayMs: 250, jitter: false },
29
+ },
30
+ });
31
+
32
+ expect(tool.retry).toEqual({
33
+ enabled: true,
34
+ maxAttempts: 5,
35
+ backoff: { strategy: "fixed", delayMs: 250, jitter: false },
36
+ });
37
+ });
38
+
39
+ it("omits retry policy when not provided", () => {
40
+ const tool = createActionTool({
41
+ description: "test",
42
+ args: z.object({ value: z.string() }),
43
+ handler: fakeHandler,
44
+ });
45
+
46
+ expect(tool.retry).toBeUndefined();
47
+ });
48
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isToolInputAvailablePart } from "./messages";
3
+
4
+ describe("isToolInputAvailablePart", () => {
5
+ it("returns true for tool input available objects", () => {
6
+ expect(
7
+ isToolInputAvailablePart({
8
+ toolCallId: "tool-call-1",
9
+ state: "input-available",
10
+ }),
11
+ ).toBe(true);
12
+ });
13
+
14
+ it("returns false for non-object values", () => {
15
+ expect(isToolInputAvailablePart(null)).toBe(false);
16
+ expect(isToolInputAvailablePart("tool-call-1")).toBe(false);
17
+ expect(isToolInputAvailablePart(123)).toBe(false);
18
+ expect(isToolInputAvailablePart(true)).toBe(false);
19
+ });
20
+
21
+ it("returns false when required fields are missing or invalid", () => {
22
+ expect(
23
+ isToolInputAvailablePart({
24
+ state: "input-available",
25
+ }),
26
+ ).toBe(false);
27
+ expect(
28
+ isToolInputAvailablePart({
29
+ toolCallId: 123,
30
+ state: "input-available",
31
+ }),
32
+ ).toBe(false);
33
+ expect(
34
+ isToolInputAvailablePart({
35
+ toolCallId: "tool-call-1",
36
+ state: "output-available",
37
+ }),
38
+ ).toBe(false);
39
+ });
40
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { UIMessageChunk } from "ai";
3
+ import { STREAM_HEARTBEAT_INTERVAL_MS, STREAM_LIVENESS_THRESHOLD_MS } from "../utils/streaming";
4
+ import { isAlive, replacePartIds } from "./streams";
5
+
6
+ describe("isAlive", () => {
7
+ it("uses a liveness threshold greater than the heartbeat interval", () => {
8
+ expect(STREAM_LIVENESS_THRESHOLD_MS).toBeGreaterThan(STREAM_HEARTBEAT_INTERVAL_MS);
9
+ });
10
+
11
+ it("returns true for a recent streaming heartbeat", () => {
12
+ const stream = {
13
+ state: {
14
+ kind: "streaming",
15
+ lockId: "lock-id",
16
+ lastHeartbeat: Date.now(),
17
+ },
18
+ } as Exclude<Parameters<typeof isAlive>[0], null>;
19
+ expect(isAlive(stream)).toBe(true);
20
+ });
21
+
22
+ it("returns false for an old streaming heartbeat", () => {
23
+ const stream = {
24
+ state: {
25
+ kind: "streaming",
26
+ lockId: "lock-id",
27
+ lastHeartbeat: Date.now() - STREAM_LIVENESS_THRESHOLD_MS - 1,
28
+ },
29
+ } as Exclude<Parameters<typeof isAlive>[0], null>;
30
+ expect(isAlive(stream)).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe("replacePartIds", () => {
35
+ it("returns empty array for empty input", () => {
36
+ expect(replacePartIds([], new Map(), new Set())).toEqual([]);
37
+ });
38
+
39
+ it("assigns sequential ids starting from 0", () => {
40
+ const parts: UIMessageChunk[] = [
41
+ { type: "text-delta", id: "orig-a", delta: "Hello" },
42
+ { type: "text-delta", id: "orig-b", delta: " world" },
43
+ ];
44
+ const result = replacePartIds(parts, new Map(), new Set());
45
+ expect(result).toEqual([
46
+ { type: "text-delta", id: "0", delta: "Hello" },
47
+ { type: "text-delta", id: "1", delta: " world" },
48
+ ]);
49
+ });
50
+
51
+ it("reuses mapped id for parts with the same original id", () => {
52
+ const parts: UIMessageChunk[] = [
53
+ { type: "text-delta", id: "orig-a", delta: "Hello" },
54
+ { type: "text-delta", id: "orig-a", delta: " again" },
55
+ ];
56
+ const result = replacePartIds(parts, new Map(), new Set());
57
+ expect(result).toEqual([
58
+ { type: "text-delta", id: "0", delta: "Hello" },
59
+ { type: "text-delta", id: "0", delta: " again" },
60
+ ]);
61
+ });
62
+
63
+ it("uses pre-existing newIds mapping", () => {
64
+ const newIds = new Map([["orig-a", "42"]]);
65
+ const parts: UIMessageChunk[] = [{ type: "text-delta", id: "orig-a", delta: "Hello" }];
66
+ const result = replacePartIds(parts, newIds, new Set());
67
+ expect(result).toEqual([{ type: "text-delta", id: "42", delta: "Hello" }]);
68
+ });
69
+
70
+ it("skips ids that are already in prevIds", () => {
71
+ const prevIds = new Set(["0", "1"]);
72
+ const parts: UIMessageChunk[] = [{ type: "text-delta", id: "orig-a", delta: "Hello" }];
73
+ const result = replacePartIds(parts, new Map(), prevIds);
74
+ expect(result).toEqual([{ type: "text-delta", id: "2", delta: "Hello" }]);
75
+ });
76
+
77
+ it("adds generated ids to prevIds", () => {
78
+ const prevIds = new Set<string>();
79
+ const parts: UIMessageChunk[] = [{ type: "text-delta", id: "orig-a", delta: "Hello" }];
80
+ replacePartIds(parts, new Map(), prevIds);
81
+ expect(prevIds.has("0")).toBe(true);
82
+ });
83
+
84
+ it("records new mappings in newIds", () => {
85
+ const newIds = new Map<string, string>();
86
+ const parts: UIMessageChunk[] = [{ type: "text-delta", id: "orig-a", delta: "Hello" }];
87
+ replacePartIds(parts, newIds, new Set());
88
+ expect(newIds.get("orig-a")).toBe("0");
89
+ });
90
+
91
+ it("passes through parts without an id field unchanged", () => {
92
+ const parts: UIMessageChunk[] = [{ type: "tool-input-delta", toolCallId: "tc1", inputTextDelta: "some input" }];
93
+ const result = replacePartIds(parts, new Map(), new Set());
94
+ expect(result).toEqual([{ type: "tool-input-delta", toolCallId: "tc1", inputTextDelta: "some input" }]);
95
+ });
96
+
97
+ it("passes through parts with a falsy id unchanged", () => {
98
+ const parts: UIMessageChunk[] = [{ type: "text-delta", id: "", delta: "Hello" }];
99
+ const result = replacePartIds(parts, new Map(), new Set());
100
+ expect(result).toEqual([{ type: "text-delta", id: "", delta: "Hello" }]);
101
+ });
102
+
103
+ it("handles a mix of parts with and without ids", () => {
104
+ const parts: UIMessageChunk[] = [
105
+ { type: "text-delta", id: "a", delta: "Hello" },
106
+ { type: "tool-input-delta", toolCallId: "tc1", inputTextDelta: "input" },
107
+ { type: "reasoning-delta", id: "b", delta: "thinking" },
108
+ { type: "text-delta", id: "a", delta: " world" },
109
+ ];
110
+ const result = replacePartIds(parts, new Map(), new Set());
111
+ expect(result).toEqual([
112
+ { type: "text-delta", id: "0", delta: "Hello" },
113
+ { type: "tool-input-delta", toolCallId: "tc1", inputTextDelta: "input" },
114
+ { type: "reasoning-delta", id: "1", delta: "thinking" },
115
+ { type: "text-delta", id: "0", delta: " world" },
116
+ ]);
117
+ });
118
+ });
@@ -0,0 +1,48 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { resetFinalizerMismatchAlertState, trackFinalizerMismatchRate } from "./threads";
3
+
4
+ describe("trackFinalizerMismatchRate", () => {
5
+ beforeEach(() => {
6
+ resetFinalizerMismatchAlertState();
7
+ });
8
+
9
+ it("alerts when mismatch threshold is reached within the window", () => {
10
+ const now = 1_000_000;
11
+ expect(trackFinalizerMismatchRate("thread-a", now)).toEqual({
12
+ windowStartedAt: now,
13
+ count: 1,
14
+ shouldAlert: false,
15
+ });
16
+ expect(trackFinalizerMismatchRate("thread-a", now + 1_000)).toEqual({
17
+ windowStartedAt: now,
18
+ count: 2,
19
+ shouldAlert: false,
20
+ });
21
+ expect(trackFinalizerMismatchRate("thread-a", now + 2_000)).toEqual({
22
+ windowStartedAt: now,
23
+ count: 3,
24
+ shouldAlert: true,
25
+ });
26
+ expect(trackFinalizerMismatchRate("thread-a", now + 3_000)).toEqual({
27
+ windowStartedAt: now,
28
+ count: 4,
29
+ shouldAlert: false,
30
+ });
31
+ });
32
+
33
+ it("resets the counter outside the alert window and isolates by thread", () => {
34
+ const now = 2_000_000;
35
+ trackFinalizerMismatchRate("thread-a", now);
36
+ trackFinalizerMismatchRate("thread-a", now + 1_000);
37
+ expect(trackFinalizerMismatchRate("thread-b", now + 1_500)).toEqual({
38
+ windowStartedAt: now + 1_500,
39
+ count: 1,
40
+ shouldAlert: false,
41
+ });
42
+ expect(trackFinalizerMismatchRate("thread-a", now + 6 * 60 * 1_000)).toEqual({
43
+ windowStartedAt: now + 6 * 60 * 1_000,
44
+ count: 1,
45
+ shouldAlert: false,
46
+ });
47
+ });
48
+ });