@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.
@@ -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
- // When true, the stream will be finished externally (e.g., atomically via addMessages)
222
- // and consumeStream should skip calling finish().
223
- #finishedExternally: boolean = false;
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
- // Once the stream has been finished externally (e.g. by the inline
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.#nextParts.push(...parts);
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
- } else if (!this.#finishedExternally) {
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
- * Mark the stream as being finished externally (e.g., atomically via addMessages).
342
- * When called, consumeStream() will skip calling finish() since it will be
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
- public markFinishedExternally(): void {
346
- this.#finishedExternally = true;
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
- // An in-flight #sendDelta started before markFinishedExternally()
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.#finishedExternally) {
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,