@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.
- package/README.md +5 -8
- package/dist/client/messages.d.ts +10 -0
- package/dist/client/messages.d.ts.map +1 -1
- package/dist/client/messages.js +1 -0
- package/dist/client/messages.js.map +1 -1
- package/dist/component/_generated/component.d.ts +10 -8
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/messages.d.ts +340 -338
- package/dist/component/messages.d.ts.map +1 -1
- package/dist/component/messages.js +74 -14
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +726 -622
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/streams.d.ts +6 -1
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +14 -15
- 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/component/vector/index.d.ts +9 -9
- package/dist/component/vector/index.d.ts.map +1 -1
- package/dist/component/vector/tables.d.ts +5 -5
- package/dist/component/vector/tables.d.ts.map +1 -1
- package/dist/component/vector/tables.js +1 -1
- package/dist/component/vector/tables.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +106 -0
- package/dist/errors.js.map +1 -0
- package/dist/validators.d.ts +1952 -1952
- package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
- package/dist/vercel/client/messages.d.ts +50 -32
- package/dist/vercel/client/messages.d.ts.map +1 -1
- package/dist/vercel/client/messages.js +2 -0
- package/dist/vercel/client/messages.js.map +1 -1
- package/dist/vercel/client/search.d.ts +475 -32
- package/dist/vercel/client/search.d.ts.map +1 -1
- package/dist/vercel/client/search.js +34 -9
- package/dist/vercel/client/search.js.map +1 -1
- package/dist/vercel/client/start.d.ts.map +1 -1
- package/dist/vercel/client/start.js +1 -0
- package/dist/vercel/client/start.js.map +1 -1
- package/dist/vercel/client/streamText.d.ts +1 -2
- package/dist/vercel/client/streamText.d.ts.map +1 -1
- package/dist/vercel/client/streamText.js +79 -22
- package/dist/vercel/client/streamText.js.map +1 -1
- package/dist/vercel/client/streaming.d.ts +326 -305
- package/dist/vercel/client/streaming.d.ts.map +1 -1
- package/dist/vercel/client/streaming.js +140 -25
- package/dist/vercel/client/streaming.js.map +1 -1
- package/dist/vercel/client/types.d.ts +2 -0
- package/dist/vercel/client/types.d.ts.map +1 -1
- package/dist/vercel/client/types.js.map +1 -1
- package/dist/vercel/client/utils.d.ts +9 -5
- package/dist/vercel/client/utils.d.ts.map +1 -1
- package/dist/vercel/client/utils.js +13 -13
- package/dist/vercel/client/utils.js.map +1 -1
- package/dist/vercel/index.d.ts +91 -90
- package/dist/vercel/index.d.ts.map +1 -1
- package/dist/vercel/index.js +2 -0
- package/dist/vercel/index.js.map +1 -1
- package/package.json +16 -9
- package/src/client/messages.ts +12 -0
- package/src/component/_generated/component.ts +10 -0
- package/src/component/messages.test.ts +404 -0
- package/src/component/messages.ts +94 -12
- package/src/component/streams.test.ts +54 -1
- package/src/component/streams.ts +20 -17
- package/src/component/threads.test.ts +39 -0
- package/src/component/threads.ts +1 -0
- package/src/component/vector/tables.ts +1 -1
- package/src/errors.test.ts +76 -0
- package/src/errors.ts +113 -0
- package/src/vercel/client/deltaFlush.test.ts +106 -0
- package/src/vercel/client/index.test.ts +47 -0
- package/src/vercel/client/messages.ts +20 -0
- package/src/vercel/client/search.test.ts +183 -8
- package/src/vercel/client/search.ts +46 -12
- package/src/vercel/client/start.ts +1 -0
- package/src/vercel/client/streamText.test.ts +577 -18
- package/src/vercel/client/streamText.ts +84 -32
- package/src/vercel/client/streaming.integration.test.ts +40 -3
- package/src/vercel/client/streaming.test.ts +101 -7
- package/src/vercel/client/streaming.throttle.test.ts +144 -0
- package/src/vercel/client/streaming.ts +161 -25
- package/src/vercel/client/types.ts +2 -0
- package/src/vercel/client/utils.test.ts +97 -0
- package/src/vercel/client/utils.ts +15 -15
- 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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
);
|
|
236
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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("
|
|
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(
|
|
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("
|
|
378
|
+
expect(abortReason).toBe("provider_disconnected: Provider dropped");
|
|
318
379
|
expect(runMutation).toHaveBeenNthCalledWith(
|
|
319
380
|
3,
|
|
320
381
|
components.agent.streams.abort,
|
|
321
|
-
{
|
|
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
|
+
});
|