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.
- package/README.md +1 -0
- package/dist/client/handler.d.ts +4 -0
- package/dist/client/handler.d.ts.map +1 -1
- package/dist/client/handler.js +25 -3
- package/dist/client/handler.js.map +1 -1
- package/dist/client/streamer.d.ts +3 -1
- package/dist/client/streamer.d.ts.map +1 -1
- package/dist/client/streamer.js +9 -3
- package/dist/client/streamer.js.map +1 -1
- package/dist/component/tool_calls.d.ts +1 -0
- package/dist/component/tool_calls.d.ts.map +1 -1
- package/dist/component/tool_calls.js +7 -0
- package/dist/component/tool_calls.js.map +1 -1
- package/dist/react/test/happy-dom-setup.d.ts +2 -0
- package/dist/react/test/happy-dom-setup.d.ts.map +1 -0
- package/dist/react/test/happy-dom-setup.js +28 -0
- package/dist/react/test/happy-dom-setup.js.map +1 -0
- package/dist/utils/msg.d.ts +3 -0
- package/dist/utils/msg.d.ts.map +1 -0
- package/dist/utils/msg.js +7 -0
- package/dist/utils/msg.js.map +1 -0
- package/package.json +24 -21
- package/src/client/handler.ts +33 -2
- package/src/client/streamer.test.ts +187 -0
- package/src/client/streamer.ts +10 -3
- package/src/client/tools.test.ts +48 -0
- package/src/component/messages.test.ts +40 -0
- package/src/component/streams.test.ts +118 -0
- package/src/component/threads.test.ts +48 -0
- package/src/component/tool_calls.ts +9 -0
- package/src/react/__fixtures__/01-early-streaming-start.json +35 -0
- package/src/react/__fixtures__/02-reasoning-complete-tool-call.json +85 -0
- package/src/react/__fixtures__/03-new-round-seq2.json +89 -0
- package/src/react/__fixtures__/04-tool-call-error-seq3.json +145 -0
- package/src/react/__fixtures__/05-later-round-seq5.json +117 -0
- package/src/react/__fixtures__/06-text-streaming-seq6.json +162 -0
- package/src/react/__fixtures__/07-text-streaming-more-seq6.json +212 -0
- package/src/react/__fixtures__/08-fully-committed-seq6.json +188 -0
- package/src/react/__snapshots__/apply-streaming-updates.test.ts.snap +1357 -0
- package/src/react/__snapshots__/use-thread-messages.test.tsx.snap +1429 -0
- package/src/react/agent-chat.test.tsx +155 -0
- package/src/react/apply-streaming-updates.test.ts +28 -0
- package/src/react/test/happy-dom-setup.ts +31 -0
- package/src/react/use-thread-messages.test.tsx +702 -0
- package/src/utils/msg.test.ts +34 -0
- package/src/utils/msg.ts +8 -0
- 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.
|
|
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.
|
|
87
|
-
"@convex-dev/eslint-plugin": "^
|
|
88
|
-
"@convex-dev/workpool": "^0.
|
|
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.
|
|
90
|
+
"@eslint/eslintrc": "^3.3.5",
|
|
91
91
|
"@eslint/js": "9.39.2",
|
|
92
|
-
"@
|
|
93
|
-
"@types/
|
|
94
|
-
"@types/
|
|
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": "^
|
|
97
|
-
"ai": "^6.0.
|
|
97
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
98
|
+
"ai": "^6.0.174",
|
|
98
99
|
"chokidar-cli": "3.0.0",
|
|
99
|
-
"convex": "1.
|
|
100
|
-
"convex-test": "0.0.
|
|
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.
|
|
105
|
-
"eslint-plugin-react-refresh": "^0.5.
|
|
106
|
-
"globals": "^17.
|
|
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.
|
|
111
|
-
"react-dom": "^19.2.
|
|
112
|
+
"react": "^19.2.5",
|
|
113
|
+
"react-dom": "^19.2.5",
|
|
112
114
|
"typescript": "5.9.3",
|
|
113
|
-
"typescript-eslint": "8.
|
|
114
|
-
"vite": "
|
|
115
|
+
"typescript-eslint": "8.56.1",
|
|
116
|
+
"vite": "8.0.10",
|
|
115
117
|
"vitest": "4.0.18",
|
|
116
|
-
"zod": "^4.
|
|
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
|
}
|
package/src/client/handler.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
+
});
|
package/src/client/streamer.ts
CHANGED
|
@@ -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
|
|
185
|
-
|
|
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
|
+
});
|