@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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
|
2
|
+
import { createThread } from "../../client/threads.js";
|
|
3
|
+
import { components, initConvexTest } from "./setup.test.js";
|
|
4
|
+
import { DeltaStreamer } from "./streaming.js";
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("publishes a quiet tail after preparing an earlier batch takes time", async () => {
|
|
16
|
+
const t = initConvexTest();
|
|
17
|
+
let releasePreparation!: () => void;
|
|
18
|
+
let enteredPreparation!: () => void;
|
|
19
|
+
const preparing = new Promise<void>((resolve) => {
|
|
20
|
+
enteredPreparation = resolve;
|
|
21
|
+
});
|
|
22
|
+
const release = new Promise<void>((resolve) => {
|
|
23
|
+
releasePreparation = resolve;
|
|
24
|
+
});
|
|
25
|
+
await t.action(async (ctx) => {
|
|
26
|
+
const threadId = await createThread(ctx, components.agent, {});
|
|
27
|
+
const streamer = new DeltaStreamer<string>(
|
|
28
|
+
components.agent,
|
|
29
|
+
ctx,
|
|
30
|
+
{
|
|
31
|
+
throttleMs: 100,
|
|
32
|
+
compress: null,
|
|
33
|
+
abortSignal: undefined,
|
|
34
|
+
onAsyncAbort: async (reason) => {
|
|
35
|
+
throw new Error(reason);
|
|
36
|
+
},
|
|
37
|
+
materialize: async (parts) => {
|
|
38
|
+
if (parts.includes("first")) {
|
|
39
|
+
enteredPreparation();
|
|
40
|
+
await release;
|
|
41
|
+
}
|
|
42
|
+
return { parts, fileRefs: [] };
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{ threadId, order: 0, stepOrder: 0, format: undefined },
|
|
46
|
+
);
|
|
47
|
+
const savedParts = async () => {
|
|
48
|
+
const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
|
|
49
|
+
threadId,
|
|
50
|
+
cursors: [{ streamId: await streamer.getStreamId(), cursor: 0 }],
|
|
51
|
+
});
|
|
52
|
+
return deltas.flatMap((delta) => delta.parts);
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
await streamer.addParts(["first"]);
|
|
56
|
+
await preparing;
|
|
57
|
+
await streamer.addParts(["tail"]);
|
|
58
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
59
|
+
expect(await savedParts()).toEqual([]);
|
|
60
|
+
releasePreparation();
|
|
61
|
+
for (let elapsed = 0; elapsed < 200; elapsed++) {
|
|
62
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
63
|
+
if ((await savedParts()).includes("first")) break;
|
|
64
|
+
}
|
|
65
|
+
expect(await savedParts()).toEqual(["first"]);
|
|
66
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
67
|
+
expect(await savedParts()).toEqual(["first"]);
|
|
68
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
69
|
+
expect.soft(await savedParts()).toEqual(["first", "tail"]);
|
|
70
|
+
const streams = await ctx.runQuery(components.agent.streams.list, {
|
|
71
|
+
threadId,
|
|
72
|
+
statuses: ["streaming"],
|
|
73
|
+
});
|
|
74
|
+
expect(streams).toHaveLength(1);
|
|
75
|
+
} finally {
|
|
76
|
+
releasePreparation();
|
|
77
|
+
await streamer.finish();
|
|
78
|
+
}
|
|
79
|
+
expect(await savedParts()).toEqual(["first", "tail"]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a wake armed during a write respects the deadline that write set", async () => {
|
|
84
|
+
const t = initConvexTest();
|
|
85
|
+
let releasePreparation!: () => void;
|
|
86
|
+
let enteredPreparation!: () => void;
|
|
87
|
+
const preparing = new Promise<void>((resolve) => {
|
|
88
|
+
enteredPreparation = resolve;
|
|
89
|
+
});
|
|
90
|
+
const release = new Promise<void>((resolve) => {
|
|
91
|
+
releasePreparation = resolve;
|
|
92
|
+
});
|
|
93
|
+
await t.action(async (ctx) => {
|
|
94
|
+
const threadId = await createThread(ctx, components.agent, {});
|
|
95
|
+
const streamer = new DeltaStreamer<string>(
|
|
96
|
+
components.agent,
|
|
97
|
+
ctx,
|
|
98
|
+
{
|
|
99
|
+
throttleMs: 100,
|
|
100
|
+
compress: null,
|
|
101
|
+
abortSignal: undefined,
|
|
102
|
+
onAsyncAbort: async () => {},
|
|
103
|
+
materialize: async (parts) => {
|
|
104
|
+
if (parts.includes("first")) {
|
|
105
|
+
enteredPreparation();
|
|
106
|
+
await release;
|
|
107
|
+
}
|
|
108
|
+
return { parts, fileRefs: [] };
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{ threadId, order: 0, stepOrder: 0, format: undefined },
|
|
112
|
+
);
|
|
113
|
+
const streamId = await streamer.getStreamId();
|
|
114
|
+
const savedParts = async () => {
|
|
115
|
+
const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
|
|
116
|
+
threadId,
|
|
117
|
+
cursors: [{ streamId, cursor: 0 }],
|
|
118
|
+
});
|
|
119
|
+
return deltas.flatMap((delta) => delta.parts);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
await streamer.addParts(["first"]);
|
|
123
|
+
await preparing;
|
|
124
|
+
// The tail is admitted while the first batch is still being prepared, so
|
|
125
|
+
// the wake it arms is measured against the previous write's deadline.
|
|
126
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
127
|
+
await streamer.addParts(["tail"]);
|
|
128
|
+
releasePreparation();
|
|
129
|
+
for (let elapsed = 0; elapsed < 200; elapsed++) {
|
|
130
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
131
|
+
if ((await savedParts()).includes("first")) break;
|
|
132
|
+
}
|
|
133
|
+
expect(await savedParts()).toEqual(["first"]);
|
|
134
|
+
|
|
135
|
+
// The first batch has now reset the window, so the tail is not due yet.
|
|
136
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
137
|
+
expect(await savedParts()).toEqual(["first"]);
|
|
138
|
+
|
|
139
|
+
// It still has to arrive once the window it was re-armed against elapses,
|
|
140
|
+
// otherwise holding it back would pass this test by never publishing.
|
|
141
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
142
|
+
expect(await savedParts()).toEqual(["first", "tail"]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -133,6 +133,11 @@ export async function listStreams(
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
export type StreamingOptions = {
|
|
136
|
+
/**
|
|
137
|
+
* Whether source parts emitted by the model are included in the persisted
|
|
138
|
+
* delta stream. Defaults to false.
|
|
139
|
+
*/
|
|
140
|
+
sendSources?: boolean;
|
|
136
141
|
/**
|
|
137
142
|
* The minimum granularity of deltas to save.
|
|
138
143
|
* Note: this is not a guarantee that every delta will be exactly one line.
|
|
@@ -156,6 +161,7 @@ export type StreamingOptions = {
|
|
|
156
161
|
returnImmediately?: boolean;
|
|
157
162
|
};
|
|
158
163
|
export const DEFAULT_STREAMING_OPTIONS = {
|
|
164
|
+
sendSources: false,
|
|
159
165
|
// This chunks by sentences / clauses. Punctuation followed by whitespace.
|
|
160
166
|
chunking: /[\p{P}\s]/u,
|
|
161
167
|
throttleMs: 250,
|
|
@@ -215,12 +221,22 @@ export class DeltaStreamer<T> {
|
|
|
215
221
|
#nextParts: T[] = [];
|
|
216
222
|
#latestWrite: number = 0;
|
|
217
223
|
#ongoingWrite: Promise<void> | undefined;
|
|
224
|
+
#flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
218
225
|
#abortPromise: Promise<void> | undefined;
|
|
219
226
|
#cursor: number = 0;
|
|
220
227
|
public abortController: AbortController;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
228
|
+
/**
|
|
229
|
+
* When true, external code finishes the stream row (atomically with the
|
|
230
|
+
* message save, issue #181) and `consumeStream` must not. Decided once, at
|
|
231
|
+
* construction.
|
|
232
|
+
*/
|
|
233
|
+
#finishHandledExternally: boolean;
|
|
234
|
+
/**
|
|
235
|
+
* Set only where the row is finished before the source is drained. An
|
|
236
|
+
* optimization, not a race guard: `addDelta` already returns false for a
|
|
237
|
+
* non-streaming row.
|
|
238
|
+
*/
|
|
239
|
+
#stoppedAccepting: boolean = false;
|
|
224
240
|
|
|
225
241
|
constructor(
|
|
226
242
|
public readonly component: AgentComponent,
|
|
@@ -230,6 +246,8 @@ export class DeltaStreamer<T> {
|
|
|
230
246
|
onAsyncAbort: (reason: string) => Promise<void>;
|
|
231
247
|
abortSignal: AbortSignal | undefined;
|
|
232
248
|
compress: ((parts: T[]) => T[]) | null;
|
|
249
|
+
/** Defaults to false, meaning `consumeStream` finishes the stream. */
|
|
250
|
+
finishHandledExternally?: boolean;
|
|
233
251
|
materialize?: (parts: T[]) => Promise<{
|
|
234
252
|
parts: T[];
|
|
235
253
|
fileRefs: Array<{ url: string; fileId: string }>;
|
|
@@ -253,6 +271,7 @@ export class DeltaStreamer<T> {
|
|
|
253
271
|
compress: config.compress,
|
|
254
272
|
materialize: config.materialize ?? null,
|
|
255
273
|
};
|
|
274
|
+
this.#finishHandledExternally = config.finishHandledExternally ?? false;
|
|
256
275
|
this.#nextParts = [];
|
|
257
276
|
this.abortController = new AbortController();
|
|
258
277
|
if (config.abortSignal) {
|
|
@@ -294,23 +313,64 @@ export class DeltaStreamer<T> {
|
|
|
294
313
|
if (this.abortController.signal.aborted) {
|
|
295
314
|
return;
|
|
296
315
|
}
|
|
297
|
-
|
|
298
|
-
// save in streamText's onStepFinish for the returnImmediately path),
|
|
299
|
-
// the stream record is already "finished" in the DB. Late deltas
|
|
300
|
-
// would be silently dropped by streams.addDelta — skip the work.
|
|
301
|
-
if (this.#finishedExternally) {
|
|
316
|
+
if (this.#stoppedAccepting) {
|
|
302
317
|
return;
|
|
303
318
|
}
|
|
319
|
+
// Buffer before awaiting: a part parked in stream creation would be
|
|
320
|
+
// invisible to #flushPendingParts.
|
|
321
|
+
this.#nextParts.push(...parts);
|
|
304
322
|
const streamId = await this.getOrCreateStreamId({
|
|
305
323
|
ifAborted: "returnUndefined",
|
|
306
324
|
});
|
|
307
325
|
if (!streamId) return;
|
|
308
|
-
this.#
|
|
326
|
+
if (this.#stoppedAccepting || this.abortController.signal.aborted) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
309
329
|
if (
|
|
310
330
|
!this.#ongoingWrite &&
|
|
311
331
|
Date.now() - this.#latestWrite >= this.config.throttleMs
|
|
312
332
|
) {
|
|
313
333
|
this.#ongoingWrite = this.#sendDelta();
|
|
334
|
+
} else {
|
|
335
|
+
this.#scheduleFlush();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// The throttle is only reconsidered when the next part arrives, so a pause in
|
|
340
|
+
// the stream would otherwise hold whatever is buffered until it resumes.
|
|
341
|
+
#scheduleFlush() {
|
|
342
|
+
if (this.#flushTimer) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const wait = Math.max(
|
|
346
|
+
0,
|
|
347
|
+
this.config.throttleMs - (Date.now() - this.#latestWrite),
|
|
348
|
+
);
|
|
349
|
+
this.#flushTimer = setTimeout(() => {
|
|
350
|
+
this.#flushTimer = undefined;
|
|
351
|
+
if (
|
|
352
|
+
this.#ongoingWrite ||
|
|
353
|
+
this.#nextParts.length === 0 ||
|
|
354
|
+
this.#stoppedAccepting ||
|
|
355
|
+
this.abortController.signal.aborted
|
|
356
|
+
) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
// A write can start and land while this wake is armed, which moves the
|
|
360
|
+
// deadline out from under it. Re-arm against the current one rather
|
|
361
|
+
// than publishing early.
|
|
362
|
+
if (Date.now() - this.#latestWrite < this.config.throttleMs) {
|
|
363
|
+
this.#scheduleFlush();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
this.#ongoingWrite = this.#sendDelta();
|
|
367
|
+
}, wait);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#cancelScheduledFlush() {
|
|
371
|
+
if (this.#flushTimer) {
|
|
372
|
+
clearTimeout(this.#flushTimer);
|
|
373
|
+
this.#flushTimer = undefined;
|
|
314
374
|
}
|
|
315
375
|
}
|
|
316
376
|
|
|
@@ -326,24 +386,71 @@ export class DeltaStreamer<T> {
|
|
|
326
386
|
await this.#abort(errorToString(error)).catch(() => {});
|
|
327
387
|
throw error;
|
|
328
388
|
}
|
|
329
|
-
// Skip finish if it will be handled externally (atomically with message save)
|
|
330
|
-
// or if the stream was aborted (e.g., due to a failed delta write).
|
|
331
389
|
// Abort cleanup owns the terminal component transition, so consumeStream
|
|
332
390
|
// must wait for it instead of also trying to finish the stream.
|
|
333
391
|
if (this.abortController.signal.aborted) {
|
|
334
392
|
await this.#waitForAbortCleanup();
|
|
335
|
-
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// EOF is the only point where every part has actually been handed over:
|
|
396
|
+
// parts sit in the AI SDK's transform and tee pipeline until the iterator
|
|
397
|
+
// yields them, so no earlier callback can observe them.
|
|
398
|
+
try {
|
|
399
|
+
await this.#flushPendingParts();
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (this.abortController.signal.aborted) {
|
|
402
|
+
await this.#waitForAbortCleanup();
|
|
403
|
+
}
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
if (this.abortController.signal.aborted) {
|
|
407
|
+
await this.#waitForAbortCleanup();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (!this.#finishHandledExternally) {
|
|
336
411
|
await this.finish();
|
|
337
412
|
}
|
|
338
413
|
}
|
|
339
414
|
|
|
340
415
|
/**
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
* handled elsewhere in the same mutation as message saving.
|
|
416
|
+
* Drain everything currently buffered or in flight, so that after this
|
|
417
|
+
* resolves no delta write is outstanding and #nextParts is empty.
|
|
344
418
|
*/
|
|
345
|
-
|
|
346
|
-
this
|
|
419
|
+
async #flushPendingParts(): Promise<void> {
|
|
420
|
+
while (!this.abortController.signal.aborted) {
|
|
421
|
+
const inFlight = this.#ongoingWrite;
|
|
422
|
+
await inFlight;
|
|
423
|
+
// #sendDelta reassigns #ongoingWrite from its own tail, so a write can
|
|
424
|
+
// still be live even though the buffer it drained is now empty.
|
|
425
|
+
if (this.#ongoingWrite !== inFlight) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (this.#nextParts.length === 0) {
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
this.#ongoingWrite = this.#sendDelta();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* For the `returnImmediately` path, where nothing awaits consumption and the
|
|
437
|
+
* save has to happen inline (issue #265).
|
|
438
|
+
*
|
|
439
|
+
* Inherent window: parts still inside the AI SDK pipeline at this instant
|
|
440
|
+
* never reach addParts, so they are never persisted as deltas (the
|
|
441
|
+
* stream-level `finish` chunk among them). The message saved alongside this
|
|
442
|
+
* transition is complete, and is authoritative once the stream is finished.
|
|
443
|
+
*
|
|
444
|
+
* Narrower race left open: a part admitted after the loop's last emptiness
|
|
445
|
+
* check is buffered but not drained here, and the write it triggers at EOF
|
|
446
|
+
* lands on a row the caller has since finished. `#sendDelta` tolerates that
|
|
447
|
+
* refusal rather than aborting. Closing it means refusing admission for the
|
|
448
|
+
* duration of the drain, which drops more of the tail than it saves.
|
|
449
|
+
*/
|
|
450
|
+
public async flushAndStopAccepting(): Promise<void> {
|
|
451
|
+
this.#cancelScheduledFlush();
|
|
452
|
+
await this.#flushPendingParts();
|
|
453
|
+
this.#stoppedAccepting = true;
|
|
347
454
|
}
|
|
348
455
|
|
|
349
456
|
/**
|
|
@@ -373,11 +480,13 @@ export class DeltaStreamer<T> {
|
|
|
373
480
|
}
|
|
374
481
|
|
|
375
482
|
async #sendDelta() {
|
|
483
|
+
this.#cancelScheduledFlush();
|
|
376
484
|
if (this.abortController.signal.aborted) {
|
|
377
485
|
return;
|
|
378
486
|
}
|
|
379
487
|
let success: boolean;
|
|
380
488
|
try {
|
|
489
|
+
await this.getStreamId();
|
|
381
490
|
const delta = await this.#createDelta();
|
|
382
491
|
if (!delta) {
|
|
383
492
|
return;
|
|
@@ -392,11 +501,11 @@ export class DeltaStreamer<T> {
|
|
|
392
501
|
return;
|
|
393
502
|
}
|
|
394
503
|
if (!success) {
|
|
395
|
-
//
|
|
504
|
+
// A #sendDelta racing the inline save on the returnImmediately path
|
|
396
505
|
// will get `success === false` because the stream row is already
|
|
397
506
|
// "finished". That's a benign late-write miss, not a failure —
|
|
398
507
|
// don't convert it into an abort.
|
|
399
|
-
if (this.#
|
|
508
|
+
if (this.#stoppedAccepting) {
|
|
400
509
|
return;
|
|
401
510
|
}
|
|
402
511
|
await this.#abortDelta("async abort");
|
|
@@ -411,6 +520,11 @@ export class DeltaStreamer<T> {
|
|
|
411
520
|
this.#ongoingWrite = this.#sendDelta();
|
|
412
521
|
} else {
|
|
413
522
|
this.#ongoingWrite = undefined;
|
|
523
|
+
// Whatever is still buffered has just lost its owner: this write is
|
|
524
|
+
// over and no arrival is guaranteed to follow. Hand it to the timer.
|
|
525
|
+
if (this.#nextParts.length > 0) {
|
|
526
|
+
this.#scheduleFlush();
|
|
527
|
+
}
|
|
414
528
|
}
|
|
415
529
|
}
|
|
416
530
|
|
|
@@ -447,6 +561,7 @@ export class DeltaStreamer<T> {
|
|
|
447
561
|
}
|
|
448
562
|
|
|
449
563
|
public async finish() {
|
|
564
|
+
this.#cancelScheduledFlush();
|
|
450
565
|
if (!this.streamId) {
|
|
451
566
|
return;
|
|
452
567
|
}
|
|
@@ -491,6 +606,7 @@ export class DeltaStreamer<T> {
|
|
|
491
606
|
|
|
492
607
|
#abort(reason: string, waitForOngoingWrite = true): Promise<void> {
|
|
493
608
|
if (!this.#abortPromise) {
|
|
609
|
+
this.#cancelScheduledFlush();
|
|
494
610
|
this.abortController.abort();
|
|
495
611
|
this.#abortPromise = this.#abortCreatedStream(
|
|
496
612
|
reason,
|