@convex-dev/agent 0.7.2 → 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/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +0 -2
- 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/vercel/client/streamText.d.ts.map +1 -1
- package/dist/vercel/client/streamText.js +32 -5
- package/dist/vercel/client/streamText.js.map +1 -1
- package/dist/vercel/client/streaming.d.ts +22 -4
- package/dist/vercel/client/streaming.d.ts.map +1 -1
- package/dist/vercel/client/streaming.js +121 -19
- package/dist/vercel/client/streaming.js.map +1 -1
- package/package.json +1 -1
- package/src/component/streams.test.ts +54 -1
- package/src/component/streams.ts +0 -2
- package/src/component/threads.test.ts +39 -0
- package/src/component/threads.ts +1 -0
- package/src/vercel/client/deltaFlush.test.ts +106 -0
- package/src/vercel/client/streamText.test.ts +464 -1
- package/src/vercel/client/streamText.ts +32 -4
- package/src/vercel/client/streaming.integration.test.ts +40 -3
- package/src/vercel/client/streaming.test.ts +30 -0
- package/src/vercel/client/streaming.throttle.test.ts +144 -0
- package/src/vercel/client/streaming.ts +135 -19
|
@@ -9,9 +9,11 @@ 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
15
|
import { runStreamCleanup } from "./streamText.js";
|
|
16
|
+
import type { StreamingOptions } from "./streaming.js";
|
|
15
17
|
import { errorToString } from "./utils.js";
|
|
16
18
|
|
|
17
19
|
const schema = defineSchema({});
|
|
@@ -56,6 +58,31 @@ const failingAgent = new Agent(components.agent, {
|
|
|
56
58
|
}),
|
|
57
59
|
});
|
|
58
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
|
+
|
|
59
86
|
// Action that exercises streamText with saveStreamDeltas.returnImmediately=true.
|
|
60
87
|
// It consumes the stream after streamText returns, simulating the HTTP response
|
|
61
88
|
// path described in issue #265.
|
|
@@ -77,7 +104,6 @@ export const streamTextReturnImmediately = action({
|
|
|
77
104
|
// Drain the stream the way an HTTP response would. This triggers
|
|
78
105
|
// onStepFinish for every step, including the final one.
|
|
79
106
|
await result.consumeStream();
|
|
80
|
-
return { ok: true };
|
|
81
107
|
},
|
|
82
108
|
});
|
|
83
109
|
|
|
@@ -113,6 +139,83 @@ export const streamTextEmptyReturnImmediately = action({
|
|
|
113
139
|
},
|
|
114
140
|
});
|
|
115
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
|
+
|
|
116
219
|
export const streamTextCleanupFailure = action({
|
|
117
220
|
args: { threadId: v.string() },
|
|
118
221
|
handler: async (ctx, { threadId }) => {
|
|
@@ -150,15 +253,177 @@ export const streamTextCleanupFailure = action({
|
|
|
150
253
|
},
|
|
151
254
|
});
|
|
152
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
|
+
|
|
153
331
|
const testApi: ApiFromModules<{
|
|
154
332
|
fns: {
|
|
155
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;
|
|
156
340
|
streamTextEmptyAwaited: typeof streamTextEmptyAwaited;
|
|
157
341
|
streamTextEmptyReturnImmediately: typeof streamTextEmptyReturnImmediately;
|
|
158
342
|
streamTextCleanupFailure: typeof streamTextCleanupFailure;
|
|
343
|
+
streamTextWithSources: typeof streamTextWithSources;
|
|
159
344
|
};
|
|
160
345
|
}>["fns"] = anyApi["streamText.test"] as any;
|
|
161
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
|
+
|
|
162
427
|
describe("streamText with saveStreamDeltas.returnImmediately (issue #265)", () => {
|
|
163
428
|
test("persists the final assistant text to the messages table", async () => {
|
|
164
429
|
const t = initConvexTest(schema);
|
|
@@ -317,3 +582,201 @@ describe("streamText with an empty final step (issue #274)", () => {
|
|
|
317
582
|
},
|
|
318
583
|
);
|
|
319
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
|
+
});
|
|
@@ -9,6 +9,7 @@ import type { Context } from "@ai-sdk/provider-utils";
|
|
|
9
9
|
import { streamText as streamTextAi } from "ai";
|
|
10
10
|
import {
|
|
11
11
|
compressUIMessageChunks,
|
|
12
|
+
DEFAULT_STREAMING_OPTIONS,
|
|
12
13
|
DeltaStreamer,
|
|
13
14
|
mergeTransforms,
|
|
14
15
|
type StreamingOptions,
|
|
@@ -134,6 +135,7 @@ export async function streamText<
|
|
|
134
135
|
// When false (saveStreamDeltas.returnImmediately === true), we cannot
|
|
135
136
|
// defer the final-step save to a post-await block — the function has
|
|
136
137
|
// already returned by the time onStepFinish fires. See issue #265.
|
|
138
|
+
const savesMessages = options?.storageOptions?.saveMessages !== "none";
|
|
137
139
|
const willAwaitStream =
|
|
138
140
|
Boolean(threadId) &&
|
|
139
141
|
(options.saveStreamDeltas === true ||
|
|
@@ -155,6 +157,10 @@ export async function streamText<
|
|
|
155
157
|
materialize: (parts) =>
|
|
156
158
|
materializeUIMessageChunkFiles(ctx, component, parts),
|
|
157
159
|
abortSignal: args.abortSignal,
|
|
160
|
+
// The message save finishes the stream row atomically (issue
|
|
161
|
+
// #181) — but only when there is a save. With saveMessages set to
|
|
162
|
+
// "none" nothing does, so the streamer keeps finish ownership.
|
|
163
|
+
finishHandledExternally: savesMessages,
|
|
158
164
|
},
|
|
159
165
|
{
|
|
160
166
|
threadId,
|
|
@@ -225,10 +231,13 @@ export async function streamText<
|
|
|
225
231
|
const createPendingMessage = await willContinue(steps, args.stopWhen);
|
|
226
232
|
if (!createPendingMessage && streamer) {
|
|
227
233
|
// Final step with streaming enabled.
|
|
228
|
-
streamer.markFinishedExternally();
|
|
229
234
|
if (willAwaitStream) {
|
|
230
235
|
// We're about to `await stream` below — defer the save so it
|
|
231
|
-
// happens atomically with stream finish (issue #181).
|
|
236
|
+
// happens atomically with stream finish (issue #181). Don't touch
|
|
237
|
+
// the streamer here: the stream-level `finish` chunk is emitted
|
|
238
|
+
// after this callback, so the row has to stay `streaming` and keep
|
|
239
|
+
// accepting parts until consumeStream reaches EOF, which is the only
|
|
240
|
+
// point where everything has actually been handed over.
|
|
232
241
|
pendingFinalStep = {
|
|
233
242
|
step,
|
|
234
243
|
responseMessages: responseMessagesForStep(step),
|
|
@@ -236,7 +245,10 @@ export async function streamText<
|
|
|
236
245
|
} else {
|
|
237
246
|
// returnImmediately path: streamText is about to return without
|
|
238
247
|
// awaiting consumption, so the deferred-save block below won't
|
|
239
|
-
// see this step. Save inline now (issue #265).
|
|
248
|
+
// see this step. Save inline now (issue #265). Nothing awaits the
|
|
249
|
+
// stream here, so this is the last moment we can drain deltas, see
|
|
250
|
+
// flushAndStopAccepting for the window that leaves.
|
|
251
|
+
await streamer.flushAndStopAccepting();
|
|
240
252
|
const finishStreamId = await streamer.getOrCreateStreamId({
|
|
241
253
|
ifAborted: "returnUndefined",
|
|
242
254
|
});
|
|
@@ -246,6 +258,11 @@ export async function streamText<
|
|
|
246
258
|
false,
|
|
247
259
|
finishStreamId,
|
|
248
260
|
);
|
|
261
|
+
// The save finishes the row only when it stores messages. With
|
|
262
|
+
// saveMessages "none" nothing else will, so do it here.
|
|
263
|
+
if (!savesMessages) {
|
|
264
|
+
await streamer.finish();
|
|
265
|
+
}
|
|
249
266
|
initialResponseMessagesSaved = true;
|
|
250
267
|
}
|
|
251
268
|
}
|
|
@@ -262,7 +279,13 @@ export async function streamText<
|
|
|
262
279
|
typeof streamTextAi<Tools, RUNTIME_CONTEXT, OUTPUT>
|
|
263
280
|
>[0]) as StreamTextResult<Tools, RUNTIME_CONTEXT, OUTPUT>;
|
|
264
281
|
const stream = streamer?.consumeStream(
|
|
265
|
-
result.toUIMessageStream<AIUIMessage<Tools>>(
|
|
282
|
+
result.toUIMessageStream<AIUIMessage<Tools>>({
|
|
283
|
+
sendSources:
|
|
284
|
+
typeof options.saveStreamDeltas === "object"
|
|
285
|
+
? (options.saveStreamDeltas.sendSources ??
|
|
286
|
+
DEFAULT_STREAMING_OPTIONS.sendSources)
|
|
287
|
+
: DEFAULT_STREAMING_OPTIONS.sendSources,
|
|
288
|
+
}),
|
|
266
289
|
);
|
|
267
290
|
if (willAwaitStream) {
|
|
268
291
|
try {
|
|
@@ -300,6 +323,11 @@ export async function streamText<
|
|
|
300
323
|
await call.save(pendingFinalStep, false, finishStreamId);
|
|
301
324
|
}
|
|
302
325
|
pendingFinalStep = undefined;
|
|
326
|
+
} else if (willAwaitStream && streamer) {
|
|
327
|
+
// No final step was deferred (e.g. the generation produced none), so no
|
|
328
|
+
// save will finish the stream. The streamer doesn't finish itself, so do
|
|
329
|
+
// it here rather than leaving the row to time out.
|
|
330
|
+
await streamer.finish();
|
|
303
331
|
}
|
|
304
332
|
const metadata: GenerationOutputMetadata = {
|
|
305
333
|
promptMessageId,
|
|
@@ -190,17 +190,16 @@ describe("HTTP Streaming Initiation", () => {
|
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
-
test("
|
|
193
|
+
test("finishHandledExternally prevents consumeStream from calling finish", async () => {
|
|
194
194
|
await t.run(async (ctx) => {
|
|
195
195
|
const streamer = new DeltaStreamer(
|
|
196
196
|
components.agent,
|
|
197
197
|
ctx,
|
|
198
|
-
{ ...defaultTestOptions },
|
|
198
|
+
{ ...defaultTestOptions, finishHandledExternally: true },
|
|
199
199
|
{ ...testMetadata, threadId },
|
|
200
200
|
);
|
|
201
201
|
|
|
202
202
|
await streamer.getStreamId();
|
|
203
|
-
streamer.markFinishedExternally();
|
|
204
203
|
|
|
205
204
|
const result = streamText({
|
|
206
205
|
model: mockModel({
|
|
@@ -217,6 +216,44 @@ describe("HTTP Streaming Initiation", () => {
|
|
|
217
216
|
{ threadId, statuses: ["streaming"] },
|
|
218
217
|
);
|
|
219
218
|
expect(streamingStreams).toHaveLength(1);
|
|
219
|
+
|
|
220
|
+
// ...but the parts still made it into deltas, since the row kept
|
|
221
|
+
// accepting until end-of-stream.
|
|
222
|
+
const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
|
|
223
|
+
threadId,
|
|
224
|
+
cursors: [{ streamId: streamer.streamId!, cursor: 0 }],
|
|
225
|
+
});
|
|
226
|
+
const types = deltas.flatMap((d) => d.parts).map((p) => p.type);
|
|
227
|
+
expect(types.at(0)).toBe("start");
|
|
228
|
+
expect(types.at(-1)).toBe("finish");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("flushAndStopAccepting drops later parts but keeps the row streaming", async () => {
|
|
233
|
+
await t.run(async (ctx) => {
|
|
234
|
+
const streamer = new DeltaStreamer(
|
|
235
|
+
components.agent,
|
|
236
|
+
ctx,
|
|
237
|
+
{ ...defaultTestOptions, finishHandledExternally: true },
|
|
238
|
+
{ ...testMetadata, threadId },
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await streamer.addParts([{ type: "start" }]);
|
|
242
|
+
await streamer.flushAndStopAccepting();
|
|
243
|
+
await streamer.addParts([{ type: "finish" }]);
|
|
244
|
+
|
|
245
|
+
const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
|
|
246
|
+
threadId,
|
|
247
|
+
cursors: [{ streamId: streamer.streamId!, cursor: 0 }],
|
|
248
|
+
});
|
|
249
|
+
const types = deltas.flatMap((d) => d.parts).map((p) => p.type);
|
|
250
|
+
expect(types).toEqual(["start"]);
|
|
251
|
+
|
|
252
|
+
const streamingStreams = await ctx.runQuery(
|
|
253
|
+
components.agent.streams.list,
|
|
254
|
+
{ threadId, statuses: ["streaming"] },
|
|
255
|
+
);
|
|
256
|
+
expect(streamingStreams).toHaveLength(1);
|
|
220
257
|
});
|
|
221
258
|
});
|
|
222
259
|
});
|
|
@@ -492,4 +492,34 @@ describe("DeltaStreamer", () => {
|
|
|
492
492
|
);
|
|
493
493
|
});
|
|
494
494
|
// TODO: test fetching partial stream data - syncStreams w/ cursors
|
|
495
|
+
|
|
496
|
+
test("does not drop a part parked in stream creation when the final step lands", async () => {
|
|
497
|
+
let resolveCreate!: (streamId: string) => void;
|
|
498
|
+
const creating = new Promise<string>((r) => (resolveCreate = r));
|
|
499
|
+
const sent: unknown[] = [];
|
|
500
|
+
const runMutation = vi
|
|
501
|
+
.fn()
|
|
502
|
+
.mockImplementationOnce(() => creating)
|
|
503
|
+
.mockImplementation((_ref: unknown, args: unknown) => {
|
|
504
|
+
sent.push(args);
|
|
505
|
+
return Promise.resolve(true);
|
|
506
|
+
});
|
|
507
|
+
const streamer = new DeltaStreamer<string>(
|
|
508
|
+
components.agent,
|
|
509
|
+
{ runMutation } as unknown as MutationCtx,
|
|
510
|
+
{ ...defaultTestOptions },
|
|
511
|
+
{ ...testMetadata, threadId },
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
// A part arrives and parks in streams.create.
|
|
515
|
+
const adding = streamer.addParts(["A"]);
|
|
516
|
+
// The final step lands while creation is still in flight.
|
|
517
|
+
const finishing = streamer.flushAndStopAccepting();
|
|
518
|
+
resolveCreate("stream-1");
|
|
519
|
+
await Promise.all([adding, finishing]);
|
|
520
|
+
|
|
521
|
+
expect(sent).toHaveLength(1);
|
|
522
|
+
expect((sent[0] as { parts: string[] }).parts).toEqual(["A"]);
|
|
523
|
+
});
|
|
524
|
+
|
|
495
525
|
});
|