@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.
Files changed (90) hide show
  1. package/README.md +5 -8
  2. package/dist/client/messages.d.ts +10 -0
  3. package/dist/client/messages.d.ts.map +1 -1
  4. package/dist/client/messages.js +1 -0
  5. package/dist/client/messages.js.map +1 -1
  6. package/dist/component/_generated/component.d.ts +10 -8
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/messages.d.ts +340 -338
  9. package/dist/component/messages.d.ts.map +1 -1
  10. package/dist/component/messages.js +74 -14
  11. package/dist/component/messages.js.map +1 -1
  12. package/dist/component/schema.d.ts +726 -622
  13. package/dist/component/schema.d.ts.map +1 -1
  14. package/dist/component/streams.d.ts +6 -1
  15. package/dist/component/streams.d.ts.map +1 -1
  16. package/dist/component/streams.js +14 -15
  17. package/dist/component/streams.js.map +1 -1
  18. package/dist/component/threads.d.ts.map +1 -1
  19. package/dist/component/threads.js +1 -0
  20. package/dist/component/threads.js.map +1 -1
  21. package/dist/component/vector/index.d.ts +9 -9
  22. package/dist/component/vector/index.d.ts.map +1 -1
  23. package/dist/component/vector/tables.d.ts +5 -5
  24. package/dist/component/vector/tables.d.ts.map +1 -1
  25. package/dist/component/vector/tables.js +1 -1
  26. package/dist/component/vector/tables.js.map +1 -1
  27. package/dist/errors.d.ts +2 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +106 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/validators.d.ts +1952 -1952
  32. package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
  33. package/dist/vercel/client/messages.d.ts +50 -32
  34. package/dist/vercel/client/messages.d.ts.map +1 -1
  35. package/dist/vercel/client/messages.js +2 -0
  36. package/dist/vercel/client/messages.js.map +1 -1
  37. package/dist/vercel/client/search.d.ts +475 -32
  38. package/dist/vercel/client/search.d.ts.map +1 -1
  39. package/dist/vercel/client/search.js +34 -9
  40. package/dist/vercel/client/search.js.map +1 -1
  41. package/dist/vercel/client/start.d.ts.map +1 -1
  42. package/dist/vercel/client/start.js +1 -0
  43. package/dist/vercel/client/start.js.map +1 -1
  44. package/dist/vercel/client/streamText.d.ts +1 -2
  45. package/dist/vercel/client/streamText.d.ts.map +1 -1
  46. package/dist/vercel/client/streamText.js +79 -22
  47. package/dist/vercel/client/streamText.js.map +1 -1
  48. package/dist/vercel/client/streaming.d.ts +326 -305
  49. package/dist/vercel/client/streaming.d.ts.map +1 -1
  50. package/dist/vercel/client/streaming.js +140 -25
  51. package/dist/vercel/client/streaming.js.map +1 -1
  52. package/dist/vercel/client/types.d.ts +2 -0
  53. package/dist/vercel/client/types.d.ts.map +1 -1
  54. package/dist/vercel/client/types.js.map +1 -1
  55. package/dist/vercel/client/utils.d.ts +9 -5
  56. package/dist/vercel/client/utils.d.ts.map +1 -1
  57. package/dist/vercel/client/utils.js +13 -13
  58. package/dist/vercel/client/utils.js.map +1 -1
  59. package/dist/vercel/index.d.ts +91 -90
  60. package/dist/vercel/index.d.ts.map +1 -1
  61. package/dist/vercel/index.js +2 -0
  62. package/dist/vercel/index.js.map +1 -1
  63. package/package.json +16 -9
  64. package/src/client/messages.ts +12 -0
  65. package/src/component/_generated/component.ts +10 -0
  66. package/src/component/messages.test.ts +404 -0
  67. package/src/component/messages.ts +94 -12
  68. package/src/component/streams.test.ts +54 -1
  69. package/src/component/streams.ts +20 -17
  70. package/src/component/threads.test.ts +39 -0
  71. package/src/component/threads.ts +1 -0
  72. package/src/component/vector/tables.ts +1 -1
  73. package/src/errors.test.ts +76 -0
  74. package/src/errors.ts +113 -0
  75. package/src/vercel/client/deltaFlush.test.ts +106 -0
  76. package/src/vercel/client/index.test.ts +47 -0
  77. package/src/vercel/client/messages.ts +20 -0
  78. package/src/vercel/client/search.test.ts +183 -8
  79. package/src/vercel/client/search.ts +46 -12
  80. package/src/vercel/client/start.ts +1 -0
  81. package/src/vercel/client/streamText.test.ts +577 -18
  82. package/src/vercel/client/streamText.ts +84 -32
  83. package/src/vercel/client/streaming.integration.test.ts +40 -3
  84. package/src/vercel/client/streaming.test.ts +101 -7
  85. package/src/vercel/client/streaming.throttle.test.ts +144 -0
  86. package/src/vercel/client/streaming.ts +161 -25
  87. package/src/vercel/client/types.ts +2 -0
  88. package/src/vercel/client/utils.test.ts +97 -0
  89. package/src/vercel/client/utils.ts +15 -15
  90. package/src/vercel/index.ts +2 -0
@@ -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,
@@ -27,19 +28,22 @@ import { getModelName, getProviderName } from "../../shared.js";
27
28
  import { errorToString, willContinue } from "./utils.js";
28
29
  import { materializeUIMessageChunkFiles } from "../fileMaterialization.js";
29
30
 
30
- /** Finish every abort cleanup path before surfacing an internal failure. */
31
- export async function runAbortCleanup(cleanup: {
31
+ export async function runStreamCleanup(cleanup: {
32
32
  failCall: () => Promise<void>;
33
33
  failStreamer: () => Promise<void>;
34
34
  onAbort?: () => PromiseLike<void> | void;
35
35
  }): Promise<void> {
36
36
  const results = await Promise.allSettled([
37
- cleanup.failCall(),
38
- cleanup.failStreamer(),
37
+ Promise.resolve().then(() => cleanup.failCall()),
38
+ Promise.resolve().then(() => cleanup.failStreamer()),
39
39
  ]);
40
- await cleanup.onAbort?.();
41
- const failure = results.find((result) => result.status === "rejected");
42
- if (failure) throw failure.reason;
40
+ const [abortResult] = await Promise.allSettled([
41
+ Promise.resolve().then(() => cleanup.onAbort?.()),
42
+ ]);
43
+ const failure = [...results, abortResult].find(
44
+ (result) => result.status === "rejected",
45
+ );
46
+ if (failure?.status === "rejected") throw failure.reason;
43
47
  }
44
48
 
45
49
  /**
@@ -103,15 +107,11 @@ export async function streamText<
103
107
  Tools,
104
108
  object,
105
109
  RUNTIME_CONTEXT
106
- >(
107
- ctx,
108
- component,
109
- streamTextArgs,
110
- options,
111
- "streamText",
112
- );
110
+ >(ctx, component, streamTextArgs, options, "streamText");
113
111
 
114
112
  const steps: StepResult<Tools, RUNTIME_CONTEXT>[] = [];
113
+ let firstStreamError: string | undefined;
114
+ let streamCleanupFailure: { error: unknown } | undefined;
115
115
  let initialResponseMessages: ModelMessage[] = [];
116
116
  let initialResponseMessagesSaved = false;
117
117
  const responseMessagesForStep = (
@@ -135,6 +135,7 @@ export async function streamText<
135
135
  // When false (saveStreamDeltas.returnImmediately === true), we cannot
136
136
  // defer the final-step save to a post-await block — the function has
137
137
  // already returned by the time onStepFinish fires. See issue #265.
138
+ const savesMessages = options?.storageOptions?.saveMessages !== "none";
138
139
  const willAwaitStream =
139
140
  Boolean(threadId) &&
140
141
  (options.saveStreamDeltas === true ||
@@ -156,6 +157,10 @@ export async function streamText<
156
157
  materialize: (parts) =>
157
158
  materializeUIMessageChunkFiles(ctx, component, parts),
158
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,
159
164
  },
160
165
  {
161
166
  threadId,
@@ -180,15 +185,26 @@ export async function streamText<
180
185
  ),
181
186
  onError: async (error) => {
182
187
  console.error("onError", error);
183
- await call.fail(errorToString(error.error));
184
- await streamer?.fail(errorToString(error.error));
188
+ const reason = (firstStreamError ??= errorToString(error.error));
189
+ try {
190
+ await runStreamCleanup({
191
+ failCall: () => call.fail(reason),
192
+ failStreamer: async () => streamer?.fail(reason),
193
+ });
194
+ } catch (cleanupError) {
195
+ streamCleanupFailure ??= { error: cleanupError };
196
+ console.error("Failed to clean up errored stream:", cleanupError);
197
+ }
185
198
  return streamTextArgs.onError?.(error);
186
199
  },
187
200
  onAbort: async (event) => {
201
+ const providerTriggeredAbort =
202
+ firstStreamError !== undefined && !args.abortSignal?.aborted;
203
+ if (providerTriggeredAbort) return;
188
204
  const reason = args.abortSignal?.reason
189
205
  ? errorToString(args.abortSignal.reason)
190
206
  : "streamText aborted";
191
- await runAbortCleanup({
207
+ await runStreamCleanup({
192
208
  failCall: () => call.fail(reason),
193
209
  failStreamer: async () => streamer?.fail(reason),
194
210
  onAbort: () => streamTextArgs.onAbort?.(event),
@@ -215,10 +231,13 @@ export async function streamText<
215
231
  const createPendingMessage = await willContinue(steps, args.stopWhen);
216
232
  if (!createPendingMessage && streamer) {
217
233
  // Final step with streaming enabled.
218
- streamer.markFinishedExternally();
219
234
  if (willAwaitStream) {
220
235
  // We're about to `await stream` below — defer the save so it
221
- // 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.
222
241
  pendingFinalStep = {
223
242
  step,
224
243
  responseMessages: responseMessagesForStep(step),
@@ -226,14 +245,26 @@ export async function streamText<
226
245
  } else {
227
246
  // returnImmediately path: streamText is about to return without
228
247
  // awaiting consumption, so the deferred-save block below won't
229
- // see this step. Save inline now (issue #265).
230
- const finishStreamId = await streamer.getOrCreateStreamId();
231
- await call.save(
232
- { step, responseMessages: responseMessagesForStep(step) },
233
- false,
234
- finishStreamId,
235
- );
236
- initialResponseMessagesSaved = true;
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();
252
+ const finishStreamId = await streamer.getOrCreateStreamId({
253
+ ifAborted: "returnUndefined",
254
+ });
255
+ if (finishStreamId) {
256
+ await call.save(
257
+ { step, responseMessages: responseMessagesForStep(step) },
258
+ false,
259
+ finishStreamId,
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
+ }
266
+ initialResponseMessagesSaved = true;
267
+ }
237
268
  }
238
269
  } else {
239
270
  await call.save(
@@ -248,7 +279,13 @@ export async function streamText<
248
279
  typeof streamTextAi<Tools, RUNTIME_CONTEXT, OUTPUT>
249
280
  >[0]) as StreamTextResult<Tools, RUNTIME_CONTEXT, OUTPUT>;
250
281
  const stream = streamer?.consumeStream(
251
- 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
+ }),
252
289
  );
253
290
  if (willAwaitStream) {
254
291
  try {
@@ -258,8 +295,11 @@ export async function streamText<
258
295
  // If the stream errored (e.g. onStepFinish threw), the DeltaStreamer's
259
296
  // finish() was never called, leaving the streaming message stuck in
260
297
  // "streaming" state. Clean it up by marking it as aborted.
261
- await streamer?.fail(e instanceof Error ? e.message : String(e));
262
- // Save the deferred final step if it was already generated but not yet persisted
298
+ try {
299
+ await streamer?.fail(errorToString(e));
300
+ } catch (cleanupError) {
301
+ streamCleanupFailure ??= { error: cleanupError };
302
+ }
263
303
  if (pendingFinalStep) {
264
304
  try {
265
305
  await call.save(pendingFinalStep, false);
@@ -272,10 +312,22 @@ export async function streamText<
272
312
  }
273
313
  }
274
314
 
315
+ if (streamCleanupFailure) throw streamCleanupFailure.error;
316
+
275
317
  // If we deferred the final step save, do it now with atomic stream finish.
276
318
  if (pendingFinalStep && streamer) {
277
- const finishStreamId = await streamer.getOrCreateStreamId();
278
- await call.save(pendingFinalStep, false, finishStreamId);
319
+ const finishStreamId = await streamer.getOrCreateStreamId({
320
+ ifAborted: "returnUndefined",
321
+ });
322
+ if (finishStreamId) {
323
+ await call.save(pendingFinalStep, false, finishStreamId);
324
+ }
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();
279
331
  }
280
332
  const metadata: GenerationOutputMetadata = {
281
333
  promptMessageId,
@@ -190,17 +190,16 @@ describe("HTTP Streaming Initiation", () => {
190
190
  });
191
191
  });
192
192
 
193
- test("markFinishedExternally prevents consumeStream from calling finish", async () => {
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
  });
@@ -5,10 +5,7 @@ import type { GenericSchema, SchemaDefinition } from "convex/server";
5
5
  import { streamText } from "ai";
6
6
  import { components, initConvexTest } from "./setup.test.js";
7
7
  import { mockModel } from "./mockModel.js";
8
- import {
9
- compressUIMessageChunks,
10
- DeltaStreamer,
11
- } from "./streaming.js";
8
+ import { compressUIMessageChunks, DeltaStreamer } from "./streaming.js";
12
9
  import { getParts } from "../deltas.js";
13
10
  import type { TestConvex } from "convex-test";
14
11
 
@@ -200,10 +197,71 @@ describe("DeltaStreamer", () => {
200
197
  expect(streamer.abortController.signal.aborted).toBe(true);
201
198
  await streamer.addParts(["ignored"]);
202
199
  expect(streamer.streamId).toBeUndefined();
200
+ await expect(
201
+ streamer.getOrCreateStreamId({ ifAborted: "returnUndefined" }),
202
+ ).resolves.toBeUndefined();
203
203
  await expect(streamer.getOrCreateStreamId()).rejects.toThrow(
204
204
  "Cannot create a stream after it has been aborted",
205
205
  );
206
+ await expect(streamer.getStreamId()).rejects.toThrow(
207
+ "Cannot create a stream after it has been aborted",
208
+ );
209
+ });
210
+ });
211
+
212
+ test("preserves the public throwing behavior after an existing stream aborts", async () => {
213
+ await t.run(async (ctx) => {
214
+ const streamer = new DeltaStreamer<string>(
215
+ components.agent,
216
+ ctx,
217
+ { ...defaultTestOptions },
218
+ { ...testMetadata, threadId },
219
+ );
220
+ const streamId = await streamer.getStreamId();
221
+
222
+ await streamer.fail("provider error");
223
+
224
+ expect(streamer.streamId).toBe(streamId);
225
+ await expect(streamer.getStreamId()).rejects.toThrow(
226
+ "Cannot create a stream after it has been aborted",
227
+ );
228
+ await expect(streamer.getOrCreateStreamId()).rejects.toThrow(
229
+ "Cannot create a stream after it has been aborted",
230
+ );
231
+ await expect(
232
+ streamer.getOrCreateStreamId({ ifAborted: "returnUndefined" }),
233
+ ).resolves.toBeUndefined();
234
+ });
235
+ });
236
+
237
+ test("does not return an ID when abort wins during stream creation", async () => {
238
+ let resolveCreate!: (streamId: string) => void;
239
+ const creatingStream = new Promise<string>((resolve) => {
240
+ resolveCreate = resolve;
241
+ });
242
+ const runMutation = vi
243
+ .fn()
244
+ .mockImplementationOnce(() => creatingStream)
245
+ .mockResolvedValueOnce(true);
246
+ const streamer = new DeltaStreamer<string>(
247
+ components.agent,
248
+ { runMutation } as unknown as MutationCtx,
249
+ { ...defaultTestOptions },
250
+ { ...testMetadata, threadId },
251
+ );
252
+
253
+ const getting = streamer.getOrCreateStreamId({
254
+ ifAborted: "returnUndefined",
206
255
  });
256
+ const failing = streamer.fail("provider error");
257
+ resolveCreate("stream-1");
258
+
259
+ await failing;
260
+ await expect(getting).resolves.toBeUndefined();
261
+ expect(streamer.streamId).toBe("stream-1");
262
+ await expect(streamer.getStreamId()).rejects.toThrow(
263
+ "Cannot create a stream after it has been aborted",
264
+ );
207
265
  });
208
266
 
209
267
  test("shares signal and fail cleanup while stream creation is in flight", async () => {
@@ -293,10 +351,13 @@ describe("DeltaStreamer", () => {
293
351
  });
294
352
 
295
353
  test("aborts the component stream when a delta write fails", async () => {
354
+ const deltaFailure = {
355
+ error: { code: "provider_disconnected", message: "Provider dropped" },
356
+ };
296
357
  const runMutation = vi
297
358
  .fn()
298
359
  .mockResolvedValueOnce("stream-1")
299
- .mockRejectedValueOnce(new Error("delta failed"))
360
+ .mockRejectedValueOnce(deltaFailure)
300
361
  .mockResolvedValueOnce(undefined);
301
362
  let abortReason: string | undefined;
302
363
  const streamer = new DeltaStreamer<string>(
@@ -314,11 +375,14 @@ describe("DeltaStreamer", () => {
314
375
  await streamer.addParts(["A"]);
315
376
  await streamer.finish();
316
377
 
317
- expect(abortReason).toBe("delta failed");
378
+ expect(abortReason).toBe("provider_disconnected: Provider dropped");
318
379
  expect(runMutation).toHaveBeenNthCalledWith(
319
380
  3,
320
381
  components.agent.streams.abort,
321
- { streamId: "stream-1", reason: "delta failed" },
382
+ {
383
+ streamId: "stream-1",
384
+ reason: "provider_disconnected: Provider dropped",
385
+ },
322
386
  );
323
387
  });
324
388
 
@@ -428,4 +492,34 @@ describe("DeltaStreamer", () => {
428
492
  );
429
493
  });
430
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
+
431
525
  });
@@ -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
+ });