@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.
@@ -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("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
  });
@@ -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
  });