@convex-dev/agent 0.7.1 → 0.7.3
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 +5 -8
- package/dist/client/messages.d.ts +10 -0
- package/dist/client/messages.d.ts.map +1 -1
- package/dist/client/messages.js +1 -0
- package/dist/client/messages.js.map +1 -1
- package/dist/component/_generated/component.d.ts +10 -8
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/messages.d.ts +340 -338
- package/dist/component/messages.d.ts.map +1 -1
- package/dist/component/messages.js +74 -14
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +726 -622
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/streams.d.ts +6 -1
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +14 -15
- package/dist/component/streams.js.map +1 -1
- package/dist/component/threads.d.ts.map +1 -1
- package/dist/component/threads.js +1 -0
- package/dist/component/threads.js.map +1 -1
- package/dist/component/vector/index.d.ts +9 -9
- package/dist/component/vector/index.d.ts.map +1 -1
- package/dist/component/vector/tables.d.ts +5 -5
- package/dist/component/vector/tables.d.ts.map +1 -1
- package/dist/component/vector/tables.js +1 -1
- package/dist/component/vector/tables.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +106 -0
- package/dist/errors.js.map +1 -0
- package/dist/validators.d.ts +1952 -1952
- package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
- package/dist/vercel/client/messages.d.ts +50 -32
- package/dist/vercel/client/messages.d.ts.map +1 -1
- package/dist/vercel/client/messages.js +2 -0
- package/dist/vercel/client/messages.js.map +1 -1
- package/dist/vercel/client/search.d.ts +475 -32
- package/dist/vercel/client/search.d.ts.map +1 -1
- package/dist/vercel/client/search.js +34 -9
- package/dist/vercel/client/search.js.map +1 -1
- package/dist/vercel/client/start.d.ts.map +1 -1
- package/dist/vercel/client/start.js +1 -0
- package/dist/vercel/client/start.js.map +1 -1
- package/dist/vercel/client/streamText.d.ts +1 -2
- package/dist/vercel/client/streamText.d.ts.map +1 -1
- package/dist/vercel/client/streamText.js +79 -22
- package/dist/vercel/client/streamText.js.map +1 -1
- package/dist/vercel/client/streaming.d.ts +326 -305
- package/dist/vercel/client/streaming.d.ts.map +1 -1
- package/dist/vercel/client/streaming.js +140 -25
- package/dist/vercel/client/streaming.js.map +1 -1
- package/dist/vercel/client/types.d.ts +2 -0
- package/dist/vercel/client/types.d.ts.map +1 -1
- package/dist/vercel/client/types.js.map +1 -1
- package/dist/vercel/client/utils.d.ts +9 -5
- package/dist/vercel/client/utils.d.ts.map +1 -1
- package/dist/vercel/client/utils.js +13 -13
- package/dist/vercel/client/utils.js.map +1 -1
- package/dist/vercel/index.d.ts +91 -90
- package/dist/vercel/index.d.ts.map +1 -1
- package/dist/vercel/index.js +2 -0
- package/dist/vercel/index.js.map +1 -1
- package/package.json +16 -9
- package/src/client/messages.ts +12 -0
- package/src/component/_generated/component.ts +10 -0
- package/src/component/messages.test.ts +404 -0
- package/src/component/messages.ts +94 -12
- package/src/component/streams.test.ts +54 -1
- package/src/component/streams.ts +20 -17
- package/src/component/threads.test.ts +39 -0
- package/src/component/threads.ts +1 -0
- package/src/component/vector/tables.ts +1 -1
- package/src/errors.test.ts +76 -0
- package/src/errors.ts +113 -0
- package/src/vercel/client/deltaFlush.test.ts +106 -0
- package/src/vercel/client/index.test.ts +47 -0
- package/src/vercel/client/messages.ts +20 -0
- package/src/vercel/client/search.test.ts +183 -8
- package/src/vercel/client/search.ts +46 -12
- package/src/vercel/client/start.ts +1 -0
- package/src/vercel/client/streamText.test.ts +577 -18
- package/src/vercel/client/streamText.ts +84 -32
- package/src/vercel/client/streaming.integration.test.ts +40 -3
- package/src/vercel/client/streaming.test.ts +101 -7
- package/src/vercel/client/streaming.throttle.test.ts +144 -0
- package/src/vercel/client/streaming.ts +161 -25
- package/src/vercel/client/types.ts +2 -0
- package/src/vercel/client/utils.test.ts +97 -0
- package/src/vercel/client/utils.ts +15 -15
- package/src/vercel/index.ts +2 -0
|
@@ -9,15 +9,31 @@ import {
|
|
|
9
9
|
anyApi,
|
|
10
10
|
} from "convex/server";
|
|
11
11
|
import { v } from "convex/values";
|
|
12
|
+
import type { LanguageModelV4Source } from "@ai-sdk/provider";
|
|
12
13
|
import { components, initConvexTest } from "./setup.test.js";
|
|
13
14
|
import { mockModel } from "./mockModel.js";
|
|
14
|
-
import {
|
|
15
|
+
import { runStreamCleanup } from "./streamText.js";
|
|
16
|
+
import type { StreamingOptions } from "./streaming.js";
|
|
17
|
+
import { errorToString } from "./utils.js";
|
|
15
18
|
|
|
16
19
|
const schema = defineSchema({});
|
|
17
20
|
type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
|
18
21
|
const action = actionGeneric as ActionBuilder<DataModel, "public">;
|
|
19
22
|
|
|
20
23
|
const FINAL_TEXT = "Hello from the model";
|
|
24
|
+
const PROVIDER_FAILURE_TEXT = "Mock provider failure";
|
|
25
|
+
const CLEANUP_FAILURE_TEXT = "finalizeMessage rejected";
|
|
26
|
+
|
|
27
|
+
function hasKeys(
|
|
28
|
+
value: unknown,
|
|
29
|
+
keys: string[],
|
|
30
|
+
): value is Record<string, unknown> {
|
|
31
|
+
return (
|
|
32
|
+
value !== null &&
|
|
33
|
+
typeof value === "object" &&
|
|
34
|
+
keys.every((key) => key in value)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
21
37
|
|
|
22
38
|
const agent = new Agent(components.agent, {
|
|
23
39
|
name: "stream-test",
|
|
@@ -34,6 +50,39 @@ const emptyAgent = new Agent(components.agent, {
|
|
|
34
50
|
}),
|
|
35
51
|
});
|
|
36
52
|
|
|
53
|
+
const failingAgent = new Agent(components.agent, {
|
|
54
|
+
name: "failing-stream-test",
|
|
55
|
+
languageModel: mockModel({
|
|
56
|
+
content: [{ type: "text", text: "partial response" }],
|
|
57
|
+
fail: { error: PROVIDER_FAILURE_TEXT },
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const sourceParts: LanguageModelV4Source[] = [
|
|
62
|
+
{
|
|
63
|
+
type: "source",
|
|
64
|
+
sourceType: "url",
|
|
65
|
+
id: "source-url-1",
|
|
66
|
+
url: "https://example.com/reference",
|
|
67
|
+
title: "Reference",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: "source",
|
|
71
|
+
sourceType: "document",
|
|
72
|
+
id: "source-document-1",
|
|
73
|
+
mediaType: "application/pdf",
|
|
74
|
+
title: "Document",
|
|
75
|
+
filename: "document.pdf",
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const sourceAgent = new Agent(components.agent, {
|
|
80
|
+
name: "source-stream-test",
|
|
81
|
+
languageModel: mockModel({
|
|
82
|
+
content: [{ type: "text", text: FINAL_TEXT }, ...sourceParts],
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
37
86
|
// Action that exercises streamText with saveStreamDeltas.returnImmediately=true.
|
|
38
87
|
// It consumes the stream after streamText returns, simulating the HTTP response
|
|
39
88
|
// path described in issue #265.
|
|
@@ -55,7 +104,6 @@ export const streamTextReturnImmediately = action({
|
|
|
55
104
|
// Drain the stream the way an HTTP response would. This triggers
|
|
56
105
|
// onStepFinish for every step, including the final one.
|
|
57
106
|
await result.consumeStream();
|
|
58
|
-
return { ok: true };
|
|
59
107
|
},
|
|
60
108
|
});
|
|
61
109
|
|
|
@@ -91,14 +139,291 @@ export const streamTextEmptyReturnImmediately = action({
|
|
|
91
139
|
},
|
|
92
140
|
});
|
|
93
141
|
|
|
142
|
+
export const streamTextThrottled = action({
|
|
143
|
+
args: { threadId: v.string() },
|
|
144
|
+
handler: async (ctx, { threadId }) => {
|
|
145
|
+
const result = await agent.streamText(
|
|
146
|
+
ctx,
|
|
147
|
+
{ threadId },
|
|
148
|
+
{ prompt: "Test" },
|
|
149
|
+
{
|
|
150
|
+
saveStreamDeltas: {
|
|
151
|
+
returnImmediately: true,
|
|
152
|
+
chunking: "word",
|
|
153
|
+
throttleMs: 60_000,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
await result.consumeStream();
|
|
158
|
+
return { ok: true };
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Same as streamTextThrottled, but awaited: streamText consumes the stream
|
|
163
|
+
// itself, so the terminal transition happens at end-of-stream.
|
|
164
|
+
export const streamTextThrottledAwaited = action({
|
|
165
|
+
args: { threadId: v.string() },
|
|
166
|
+
handler: async (ctx, { threadId }) => {
|
|
167
|
+
await agent.streamText(
|
|
168
|
+
ctx,
|
|
169
|
+
{ threadId },
|
|
170
|
+
{ prompt: "Test" },
|
|
171
|
+
{
|
|
172
|
+
saveStreamDeltas: {
|
|
173
|
+
chunking: "word",
|
|
174
|
+
throttleMs: 60_000,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
return { ok: true };
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
export const streamTextNoStorage = action({
|
|
183
|
+
args: { threadId: v.string() },
|
|
184
|
+
handler: async (ctx, { threadId }) => {
|
|
185
|
+
await agent.streamText(
|
|
186
|
+
ctx,
|
|
187
|
+
{ threadId },
|
|
188
|
+
{ prompt: "Test" },
|
|
189
|
+
{
|
|
190
|
+
saveStreamDeltas: { chunking: "word", throttleMs: 0 },
|
|
191
|
+
storageOptions: { saveMessages: "none" },
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
return { ok: true };
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
export const streamTextNoStorageImmediate = action({
|
|
199
|
+
args: { threadId: v.string() },
|
|
200
|
+
handler: async (ctx, { threadId }) => {
|
|
201
|
+
const r = await agent.streamText(
|
|
202
|
+
ctx,
|
|
203
|
+
{ threadId },
|
|
204
|
+
{ prompt: "Test" },
|
|
205
|
+
{
|
|
206
|
+
saveStreamDeltas: {
|
|
207
|
+
returnImmediately: true,
|
|
208
|
+
chunking: "word",
|
|
209
|
+
throttleMs: 0,
|
|
210
|
+
},
|
|
211
|
+
storageOptions: { saveMessages: "none" },
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
await r.consumeStream();
|
|
215
|
+
return { ok: true };
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
export const streamTextCleanupFailure = action({
|
|
220
|
+
args: { threadId: v.string() },
|
|
221
|
+
handler: async (ctx, { threadId }) => {
|
|
222
|
+
const providerErrors: string[] = [];
|
|
223
|
+
let aborts = 0;
|
|
224
|
+
const failingCtx = {
|
|
225
|
+
...ctx,
|
|
226
|
+
runMutation: (async (reference, args) => {
|
|
227
|
+
if (hasKeys(args, ["messageId", "result"])) {
|
|
228
|
+
throw new Error(CLEANUP_FAILURE_TEXT);
|
|
229
|
+
}
|
|
230
|
+
return ctx.runMutation(reference, args);
|
|
231
|
+
}) as typeof ctx.runMutation,
|
|
232
|
+
};
|
|
233
|
+
let caught: string | undefined;
|
|
234
|
+
try {
|
|
235
|
+
await failingAgent.streamText(
|
|
236
|
+
failingCtx,
|
|
237
|
+
{ threadId },
|
|
238
|
+
{
|
|
239
|
+
prompt: "Test",
|
|
240
|
+
onError: ({ error }) => {
|
|
241
|
+
providerErrors.push(errorToString(error));
|
|
242
|
+
},
|
|
243
|
+
onAbort: () => {
|
|
244
|
+
aborts += 1;
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{ saveStreamDeltas: { chunking: "word", throttleMs: 0 } },
|
|
248
|
+
);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
caught = errorToString(error);
|
|
251
|
+
}
|
|
252
|
+
return { providerErrors, aborts, caught };
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// A generation that someone aborts out of band while it is streaming, the way
|
|
257
|
+
// a client cancelling a request would: list the streaming row and abort it.
|
|
258
|
+
// The throttle holds every part after the first, so the only remaining delta
|
|
259
|
+
// write is the one the finishing save drains, and the component refuses it.
|
|
260
|
+
export const streamTextAbortedMidStream = action({
|
|
261
|
+
args: { threadId: v.string() },
|
|
262
|
+
handler: async (ctx, { threadId }) => {
|
|
263
|
+
const result = await agent.streamText(
|
|
264
|
+
ctx,
|
|
265
|
+
{ threadId },
|
|
266
|
+
{ prompt: "Test" },
|
|
267
|
+
{
|
|
268
|
+
saveStreamDeltas: {
|
|
269
|
+
returnImmediately: true,
|
|
270
|
+
chunking: "word",
|
|
271
|
+
throttleMs: 60_000,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
for (let i = 0; i < 50; i++) {
|
|
276
|
+
const streaming = await ctx.runQuery(components.agent.streams.list, {
|
|
277
|
+
threadId,
|
|
278
|
+
statuses: ["streaming"],
|
|
279
|
+
});
|
|
280
|
+
if (streaming.length) {
|
|
281
|
+
await ctx.runMutation(components.agent.streams.abort, {
|
|
282
|
+
streamId: streaming[0].streamId,
|
|
283
|
+
reason: "external abort",
|
|
284
|
+
});
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
288
|
+
}
|
|
289
|
+
await result.consumeStream();
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// An empty generation on the awaited path with nothing stored: no part ever
|
|
294
|
+
// reaches the streamer, so no row exists when consumption ends.
|
|
295
|
+
export const streamTextEmptyNoStorageAwaited = action({
|
|
296
|
+
args: { threadId: v.string() },
|
|
297
|
+
handler: async (ctx, { threadId }) => {
|
|
298
|
+
await emptyAgent.streamText(
|
|
299
|
+
ctx,
|
|
300
|
+
{ threadId },
|
|
301
|
+
{ prompt: "Test" },
|
|
302
|
+
{
|
|
303
|
+
saveStreamDeltas: { chunking: "word", throttleMs: 0 },
|
|
304
|
+
storageOptions: { saveMessages: "none" },
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
return { ok: true };
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
export const streamTextWithSources = action({
|
|
312
|
+
args: { threadId: v.string(), sendSources: v.optional(v.boolean()) },
|
|
313
|
+
handler: async (ctx, { threadId, sendSources }) => {
|
|
314
|
+
const saveStreamDeltas: StreamingOptions = {
|
|
315
|
+
chunking: "word",
|
|
316
|
+
throttleMs: 0,
|
|
317
|
+
};
|
|
318
|
+
if (sendSources !== undefined) {
|
|
319
|
+
saveStreamDeltas.sendSources = sendSources;
|
|
320
|
+
}
|
|
321
|
+
await sourceAgent.streamText(
|
|
322
|
+
ctx,
|
|
323
|
+
{ threadId },
|
|
324
|
+
{ prompt: "Test" },
|
|
325
|
+
{ saveStreamDeltas },
|
|
326
|
+
);
|
|
327
|
+
return { ok: true };
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
94
331
|
const testApi: ApiFromModules<{
|
|
95
332
|
fns: {
|
|
96
333
|
streamTextReturnImmediately: typeof streamTextReturnImmediately;
|
|
334
|
+
streamTextThrottled: typeof streamTextThrottled;
|
|
335
|
+
streamTextThrottledAwaited: typeof streamTextThrottledAwaited;
|
|
336
|
+
streamTextAbortedMidStream: typeof streamTextAbortedMidStream;
|
|
337
|
+
streamTextNoStorage: typeof streamTextNoStorage;
|
|
338
|
+
streamTextNoStorageImmediate: typeof streamTextNoStorageImmediate;
|
|
339
|
+
streamTextEmptyNoStorageAwaited: typeof streamTextEmptyNoStorageAwaited;
|
|
97
340
|
streamTextEmptyAwaited: typeof streamTextEmptyAwaited;
|
|
98
341
|
streamTextEmptyReturnImmediately: typeof streamTextEmptyReturnImmediately;
|
|
342
|
+
streamTextCleanupFailure: typeof streamTextCleanupFailure;
|
|
343
|
+
streamTextWithSources: typeof streamTextWithSources;
|
|
99
344
|
};
|
|
100
345
|
}>["fns"] = anyApi["streamText.test"] as any;
|
|
101
346
|
|
|
347
|
+
describe("streamText source visibility", () => {
|
|
348
|
+
test.each([
|
|
349
|
+
{ name: "omitted", sendSources: undefined },
|
|
350
|
+
{ name: "enabled", sendSources: true },
|
|
351
|
+
])(
|
|
352
|
+
"keeps awaited deltas healthy with sources $name",
|
|
353
|
+
async ({ sendSources }) => {
|
|
354
|
+
const t = initConvexTest(schema);
|
|
355
|
+
const threadId = await t.run(async (ctx) =>
|
|
356
|
+
createThread(ctx, components.agent, { userId: "u1" }),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
await t.action(testApi.streamTextWithSources, {
|
|
360
|
+
threadId,
|
|
361
|
+
...(sendSources === undefined ? {} : { sendSources }),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const streams = await t.run(async (ctx) =>
|
|
365
|
+
ctx.runQuery(components.agent.streams.list, {
|
|
366
|
+
threadId,
|
|
367
|
+
statuses: ["streaming", "finished", "aborted"],
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
expect(streams).toEqual([
|
|
371
|
+
expect.objectContaining({ status: "finished" }),
|
|
372
|
+
]);
|
|
373
|
+
const deltas = await t.run(async (ctx) =>
|
|
374
|
+
ctx.runQuery(components.agent.streams.listDeltas, {
|
|
375
|
+
threadId,
|
|
376
|
+
cursors: streams.map((stream) => ({
|
|
377
|
+
streamId: stream.streamId,
|
|
378
|
+
cursor: 0,
|
|
379
|
+
})),
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
const parts = deltas.flatMap((delta) => delta.parts);
|
|
383
|
+
expect(
|
|
384
|
+
parts
|
|
385
|
+
.filter((part) => part.type === "text-delta")
|
|
386
|
+
.map((part) => part.delta)
|
|
387
|
+
.join(""),
|
|
388
|
+
).toBe(FINAL_TEXT);
|
|
389
|
+
const streamedSources = parts.filter(
|
|
390
|
+
(part) => part.type === "source-url" || part.type === "source-document",
|
|
391
|
+
);
|
|
392
|
+
expect(streamedSources).toEqual(
|
|
393
|
+
sendSources
|
|
394
|
+
? [
|
|
395
|
+
expect.objectContaining({
|
|
396
|
+
type: "source-url",
|
|
397
|
+
sourceId: "source-url-1",
|
|
398
|
+
url: "https://example.com/reference",
|
|
399
|
+
title: "Reference",
|
|
400
|
+
}),
|
|
401
|
+
expect.objectContaining({
|
|
402
|
+
type: "source-document",
|
|
403
|
+
sourceId: "source-document-1",
|
|
404
|
+
mediaType: "application/pdf",
|
|
405
|
+
title: "Document",
|
|
406
|
+
filename: "document.pdf",
|
|
407
|
+
}),
|
|
408
|
+
]
|
|
409
|
+
: [],
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const messages = await t.run(async (ctx) =>
|
|
413
|
+
sourceAgent.listMessages(ctx, {
|
|
414
|
+
threadId,
|
|
415
|
+
paginationOpts: { cursor: null, numItems: 50 },
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
expect(
|
|
419
|
+
messages.page.filter(
|
|
420
|
+
(message) => message.message?.role === "assistant",
|
|
421
|
+
),
|
|
422
|
+
).toMatchObject([{ sources: sourceParts }]);
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
});
|
|
426
|
+
|
|
102
427
|
describe("streamText with saveStreamDeltas.returnImmediately (issue #265)", () => {
|
|
103
428
|
test("persists the final assistant text to the messages table", async () => {
|
|
104
429
|
const t = initConvexTest(schema);
|
|
@@ -147,28 +472,64 @@ describe("streamText with saveStreamDeltas.returnImmediately (issue #265)", () =
|
|
|
147
472
|
});
|
|
148
473
|
|
|
149
474
|
describe("streamText abort cleanup", () => {
|
|
150
|
-
test("
|
|
475
|
+
test("finishes durable cleanup before invoking onAbort", async () => {
|
|
151
476
|
const calls: string[] = [];
|
|
152
|
-
|
|
477
|
+
let resolveStreamer!: () => void;
|
|
478
|
+
const syncFailure = new Error("synchronous pending message cleanup");
|
|
153
479
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
480
|
+
const cleanup = runStreamCleanup({
|
|
481
|
+
failCall: () => {
|
|
482
|
+
calls.push("call.fail");
|
|
483
|
+
throw syncFailure;
|
|
484
|
+
},
|
|
485
|
+
failStreamer: () =>
|
|
486
|
+
new Promise<void>((resolve) => {
|
|
161
487
|
calls.push("streamer.fail");
|
|
162
|
-
|
|
163
|
-
},
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
488
|
+
resolveStreamer = resolve;
|
|
489
|
+
}),
|
|
490
|
+
onAbort: () => {
|
|
491
|
+
calls.push("user.onAbort");
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
await Promise.resolve();
|
|
496
|
+
expect(calls).toEqual(["call.fail", "streamer.fail"]);
|
|
497
|
+
resolveStreamer();
|
|
498
|
+
await expect(cleanup).rejects.toBe(syncFailure);
|
|
169
499
|
|
|
170
500
|
expect(calls).toEqual(["call.fail", "streamer.fail", "user.onAbort"]);
|
|
171
501
|
});
|
|
502
|
+
|
|
503
|
+
test("surfaces a cleanup failure without hiding the provider error", async () => {
|
|
504
|
+
const t = initConvexTest(schema);
|
|
505
|
+
const threadId = await t.run(async (ctx) =>
|
|
506
|
+
createThread(ctx, components.agent, { userId: "u1" }),
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const { providerErrors, aborts, caught } = await t.action(
|
|
510
|
+
testApi.streamTextCleanupFailure,
|
|
511
|
+
{ threadId },
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
expect(providerErrors).toEqual([PROVIDER_FAILURE_TEXT]);
|
|
515
|
+
expect(aborts).toBe(0);
|
|
516
|
+
expect(caught).toBe(CLEANUP_FAILURE_TEXT);
|
|
517
|
+
|
|
518
|
+
const streaming = await t.run(async (ctx) =>
|
|
519
|
+
ctx.runQuery(components.agent.streams.list, {
|
|
520
|
+
threadId,
|
|
521
|
+
statuses: ["streaming"],
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
const aborted = await t.run(async (ctx) =>
|
|
525
|
+
ctx.runQuery(components.agent.streams.list, {
|
|
526
|
+
threadId,
|
|
527
|
+
statuses: ["aborted"],
|
|
528
|
+
}),
|
|
529
|
+
);
|
|
530
|
+
expect(streaming).toHaveLength(0);
|
|
531
|
+
expect(aborted).toHaveLength(1);
|
|
532
|
+
});
|
|
172
533
|
});
|
|
173
534
|
|
|
174
535
|
describe("streamText with an empty final step (issue #274)", () => {
|
|
@@ -221,3 +582,201 @@ describe("streamText with an empty final step (issue #274)", () => {
|
|
|
221
582
|
},
|
|
222
583
|
);
|
|
223
584
|
});
|
|
585
|
+
|
|
586
|
+
describe("saveStreamDeltas flushes buffered parts (issue #323)", () => {
|
|
587
|
+
test("deltas hold the full text when the generation outpaces the throttle", async () => {
|
|
588
|
+
const t = initConvexTest(schema);
|
|
589
|
+
const threadId = await t.run(async (ctx) =>
|
|
590
|
+
createThread(ctx, components.agent, { userId: "u1" }),
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
await t.action(testApi.streamTextThrottled, { threadId });
|
|
594
|
+
await t.finishAllScheduledFunctions(() => {});
|
|
595
|
+
|
|
596
|
+
const streams = await t.run(async (ctx) =>
|
|
597
|
+
ctx.runQuery(components.agent.streams.list, {
|
|
598
|
+
threadId,
|
|
599
|
+
statuses: ["streaming", "finished", "aborted"],
|
|
600
|
+
}),
|
|
601
|
+
);
|
|
602
|
+
const deltas = await t.run(async (ctx) =>
|
|
603
|
+
ctx.runQuery(components.agent.streams.listDeltas, {
|
|
604
|
+
threadId,
|
|
605
|
+
cursors: streams.map((s) => ({ streamId: s.streamId, cursor: 0 })),
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
expect(streams).toHaveLength(1);
|
|
610
|
+
expect(streams[0].status).toBe("finished");
|
|
611
|
+
|
|
612
|
+
let cursor = 0;
|
|
613
|
+
for (const delta of deltas) {
|
|
614
|
+
expect(delta.start).toBe(cursor);
|
|
615
|
+
cursor = delta.end;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const parts = deltas.flatMap((d) => d.parts);
|
|
619
|
+
const types = parts.map((p) => p.type);
|
|
620
|
+
expect(types.at(0)).toBe("start");
|
|
621
|
+
expect(types).toContain("text-start");
|
|
622
|
+
expect(types).toContain("text-end");
|
|
623
|
+
// The stream-level "finish" chunk is emitted after the last step ends, so
|
|
624
|
+
// it cannot exist yet; the row's finished status carries that instead.
|
|
625
|
+
expect(types.at(-1)).toBe("finish-step");
|
|
626
|
+
expect(types).not.toContain("finish");
|
|
627
|
+
expect(
|
|
628
|
+
parts
|
|
629
|
+
.filter((p) => p.type === "text-delta")
|
|
630
|
+
.map((p) => (p as { delta?: string }).delta ?? "")
|
|
631
|
+
.join(""),
|
|
632
|
+
).toBe(FINAL_TEXT);
|
|
633
|
+
|
|
634
|
+
const messages = await t.run(async (ctx) =>
|
|
635
|
+
agent.listMessages(ctx, {
|
|
636
|
+
threadId,
|
|
637
|
+
paginationOpts: { cursor: null, numItems: 50 },
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
expect(
|
|
641
|
+
messages.page
|
|
642
|
+
.filter((m) => m.message?.role === "assistant")
|
|
643
|
+
.map((m) => m.text)
|
|
644
|
+
.join(""),
|
|
645
|
+
).toBe(FINAL_TEXT);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("an out of band abort fails the generation instead of saving it", async () => {
|
|
649
|
+
const t = initConvexTest(schema);
|
|
650
|
+
const threadId = await t.run(async (ctx) =>
|
|
651
|
+
createThread(ctx, components.agent, { userId: "u1" }),
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
await t.action(testApi.streamTextAbortedMidStream, { threadId });
|
|
655
|
+
await t.finishAllScheduledFunctions(() => {});
|
|
656
|
+
|
|
657
|
+
const streams = await t.run(async (ctx) =>
|
|
658
|
+
ctx.runQuery(components.agent.streams.list, {
|
|
659
|
+
threadId,
|
|
660
|
+
statuses: ["streaming", "finished", "aborted"],
|
|
661
|
+
}),
|
|
662
|
+
);
|
|
663
|
+
expect(streams).toHaveLength(1);
|
|
664
|
+
expect(streams[0].status).toBe("aborted");
|
|
665
|
+
|
|
666
|
+
// Nobody gets to save a successful message onto a row someone aborted.
|
|
667
|
+
const messages = await t.run(async (ctx) =>
|
|
668
|
+
agent.listMessages(ctx, {
|
|
669
|
+
threadId,
|
|
670
|
+
paginationOpts: { cursor: null, numItems: 50 },
|
|
671
|
+
}),
|
|
672
|
+
);
|
|
673
|
+
expect(
|
|
674
|
+
messages.page
|
|
675
|
+
.filter((m) => m.message?.role === "assistant")
|
|
676
|
+
.map((m) => m.status),
|
|
677
|
+
).toEqual(["failed"]);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("the awaited path captures the stream-level finish chunk", async () => {
|
|
681
|
+
const t = initConvexTest(schema);
|
|
682
|
+
const threadId = await t.run(async (ctx) =>
|
|
683
|
+
createThread(ctx, components.agent, { userId: "u1" }),
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
await t.action(testApi.streamTextThrottledAwaited, { threadId });
|
|
687
|
+
await t.finishAllScheduledFunctions(() => {});
|
|
688
|
+
|
|
689
|
+
const streams = await t.run(async (ctx) =>
|
|
690
|
+
ctx.runQuery(components.agent.streams.list, {
|
|
691
|
+
threadId,
|
|
692
|
+
statuses: ["streaming", "finished", "aborted"],
|
|
693
|
+
}),
|
|
694
|
+
);
|
|
695
|
+
const deltas = await t.run(async (ctx) =>
|
|
696
|
+
ctx.runQuery(components.agent.streams.listDeltas, {
|
|
697
|
+
threadId,
|
|
698
|
+
cursors: streams.map((s) => ({ streamId: s.streamId, cursor: 0 })),
|
|
699
|
+
}),
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
expect(streams).toHaveLength(1);
|
|
703
|
+
expect(streams[0].status).toBe("finished");
|
|
704
|
+
|
|
705
|
+
let cursor = 0;
|
|
706
|
+
for (const delta of deltas) {
|
|
707
|
+
expect(delta.start).toBe(cursor);
|
|
708
|
+
cursor = delta.end;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const parts = deltas.flatMap((d) => d.parts);
|
|
712
|
+
const types = parts.map((p) => p.type);
|
|
713
|
+
// Unlike the returnImmediately path, nothing stops accepting parts early
|
|
714
|
+
// here: consumeStream drains at EOF, so the trailing chunks the AI SDK
|
|
715
|
+
// emits after the last onStepEnd are persisted too.
|
|
716
|
+
expect(types.at(0)).toBe("start");
|
|
717
|
+
expect(types.at(-1)).toBe("finish");
|
|
718
|
+
expect(types).toContain("finish-step");
|
|
719
|
+
expect(
|
|
720
|
+
parts
|
|
721
|
+
.filter((p) => p.type === "text-delta")
|
|
722
|
+
.map((p) => (p as { delta?: string }).delta ?? "")
|
|
723
|
+
.join(""),
|
|
724
|
+
).toBe(FINAL_TEXT);
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
describe("stream finish ownership without message storage", () => {
|
|
729
|
+
test("the row still terminates when saveMessages is none", async () => {
|
|
730
|
+
const t = initConvexTest(schema);
|
|
731
|
+
const threadId = await t.run(async (ctx) =>
|
|
732
|
+
createThread(ctx, components.agent, { userId: "u1" }),
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
await t.action(testApi.streamTextNoStorage, { threadId });
|
|
736
|
+
await t.finishAllScheduledFunctions(() => {});
|
|
737
|
+
|
|
738
|
+
const streams = await t.run(async (ctx) =>
|
|
739
|
+
ctx.runQuery(components.agent.streams.list, {
|
|
740
|
+
threadId,
|
|
741
|
+
statuses: ["streaming", "finished", "aborted"],
|
|
742
|
+
}),
|
|
743
|
+
);
|
|
744
|
+
expect(streams.map((s) => s.status)).toEqual(["finished"]);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("leaves no row behind when the generation produces nothing", async () => {
|
|
748
|
+
const t = initConvexTest(schema);
|
|
749
|
+
const threadId = await t.run(async (ctx) =>
|
|
750
|
+
createThread(ctx, components.agent, { userId: "u1" }),
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
await t.action(testApi.streamTextEmptyNoStorageAwaited, { threadId });
|
|
754
|
+
await t.finishAllScheduledFunctions(() => {});
|
|
755
|
+
|
|
756
|
+
const streams = await t.run(async (ctx) =>
|
|
757
|
+
ctx.runQuery(components.agent.streams.list, {
|
|
758
|
+
threadId,
|
|
759
|
+
statuses: ["streaming", "finished", "aborted"],
|
|
760
|
+
}),
|
|
761
|
+
);
|
|
762
|
+
expect(streams.filter((s) => s.status === "streaming")).toEqual([]);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test("the row still terminates on the returnImmediately path", async () => {
|
|
766
|
+
const t = initConvexTest(schema);
|
|
767
|
+
const threadId = await t.run(async (ctx) =>
|
|
768
|
+
createThread(ctx, components.agent, { userId: "u1" }),
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
await t.action(testApi.streamTextNoStorageImmediate, { threadId });
|
|
772
|
+
await t.finishAllScheduledFunctions(() => {});
|
|
773
|
+
|
|
774
|
+
const streams = await t.run(async (ctx) =>
|
|
775
|
+
ctx.runQuery(components.agent.streams.list, {
|
|
776
|
+
threadId,
|
|
777
|
+
statuses: ["streaming", "finished", "aborted"],
|
|
778
|
+
}),
|
|
779
|
+
);
|
|
780
|
+
expect(streams.map((s) => s.status)).toEqual(["finished"]);
|
|
781
|
+
});
|
|
782
|
+
});
|