@convex-dev/agent 0.6.0-alpha.1 → 0.6.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/UIMessages.d.ts.map +1 -1
- package/dist/UIMessages.js +88 -0
- package/dist/UIMessages.js.map +1 -1
- package/dist/client/definePlaygroundAPI.d.ts +17 -17
- package/dist/client/index.d.ts +44 -44
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +54 -20
- package/dist/client/index.js.map +1 -1
- package/dist/client/messages.d.ts +3 -3
- package/dist/client/search.d.ts +3 -3
- package/dist/client/search.d.ts.map +1 -1
- package/dist/client/search.js +14 -4
- package/dist/client/search.js.map +1 -1
- package/dist/client/start.js +2 -2
- package/dist/client/start.js.map +1 -1
- package/dist/client/streamText.d.ts.map +1 -1
- package/dist/client/streamText.js +10 -0
- package/dist/client/streamText.js.map +1 -1
- package/dist/client/streaming.d.ts +47 -47
- package/dist/client/streaming.d.ts.map +1 -1
- package/dist/client/streaming.js +37 -21
- package/dist/client/streaming.js.map +1 -1
- package/dist/component/messages.d.ts +47 -47
- package/dist/component/schema.d.ts +40 -40
- package/dist/component/streams.d.ts +2 -2
- package/dist/component/threads.d.ts +6 -6
- package/dist/component/vector/index.d.ts +1 -1
- package/dist/mapping.d.ts +19 -15
- package/dist/mapping.d.ts.map +1 -1
- package/dist/mapping.js +90 -47
- package/dist/mapping.js.map +1 -1
- package/dist/validators.d.ts +13 -13
- package/package.json +1 -1
- package/src/UIMessages.ts +126 -0
- package/src/client/approval.test.ts +144 -0
- package/src/client/index.ts +73 -23
- package/src/client/search.test.ts +4 -5
- package/src/client/search.ts +16 -4
- package/src/client/start.ts +2 -2
- package/src/client/streamText.ts +9 -0
- package/src/client/streaming.integration.test.ts +1206 -0
- package/src/client/streaming.ts +35 -21
- package/src/mapping.test.ts +136 -71
- package/src/mapping.ts +119 -50
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { createThread } from "./index.js";
|
|
3
|
+
import type { GenericSchema, SchemaDefinition } from "convex/server";
|
|
4
|
+
import { streamText } from "ai";
|
|
5
|
+
import { components, initConvexTest } from "./setup.test.js";
|
|
6
|
+
import { mockModel } from "./mockModel.js";
|
|
7
|
+
import {
|
|
8
|
+
compressUIMessageChunks,
|
|
9
|
+
DeltaStreamer,
|
|
10
|
+
mergeTransforms,
|
|
11
|
+
} from "./streaming.js";
|
|
12
|
+
import {
|
|
13
|
+
getParts,
|
|
14
|
+
deriveUIMessagesFromDeltas,
|
|
15
|
+
deriveUIMessagesFromTextStreamParts,
|
|
16
|
+
} from "../deltas.js";
|
|
17
|
+
import type { TestConvex } from "convex-test";
|
|
18
|
+
import type { StreamDelta, StreamMessage } from "../validators.js";
|
|
19
|
+
import { dedupeMessages } from "../react/useUIMessages.js";
|
|
20
|
+
|
|
21
|
+
const defaultTestOptions = {
|
|
22
|
+
throttleMs: 0,
|
|
23
|
+
abortSignal: undefined,
|
|
24
|
+
compress: null,
|
|
25
|
+
onAsyncAbort: async (_reason: string) => {
|
|
26
|
+
// In integration tests, async aborts can happen when the stream
|
|
27
|
+
// finishes before a pending delta write completes. This is expected.
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const testMetadata = {
|
|
32
|
+
order: 0,
|
|
33
|
+
stepOrder: 0,
|
|
34
|
+
agentName: "test agent",
|
|
35
|
+
model: "test model",
|
|
36
|
+
provider: "test provider",
|
|
37
|
+
providerOptions: {},
|
|
38
|
+
format: "UIMessageChunk" as const,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// HTTP Streaming Initiation
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
describe("HTTP Streaming Initiation", () => {
|
|
46
|
+
let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
|
|
47
|
+
let threadId: string;
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
t = initConvexTest();
|
|
51
|
+
await t.run(async (ctx) => {
|
|
52
|
+
threadId = await createThread(ctx, components.agent, {});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("DeltaStreamer creates a stream on first addParts call", async () => {
|
|
57
|
+
await t.run(async (ctx) => {
|
|
58
|
+
const streamer = new DeltaStreamer(
|
|
59
|
+
components.agent,
|
|
60
|
+
ctx,
|
|
61
|
+
{ ...defaultTestOptions },
|
|
62
|
+
{ ...testMetadata, threadId },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(streamer.streamId).toBeUndefined();
|
|
66
|
+
|
|
67
|
+
await streamer.addParts([{ type: "start" }]);
|
|
68
|
+
expect(streamer.streamId).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("DeltaStreamer.getStreamId creates the stream lazily", async () => {
|
|
73
|
+
await t.run(async (ctx) => {
|
|
74
|
+
const streamer = new DeltaStreamer(
|
|
75
|
+
components.agent,
|
|
76
|
+
ctx,
|
|
77
|
+
{ ...defaultTestOptions },
|
|
78
|
+
{ ...testMetadata, threadId },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(streamer.streamId).toBeUndefined();
|
|
82
|
+
const streamId = await streamer.getStreamId();
|
|
83
|
+
expect(streamId).toBeDefined();
|
|
84
|
+
expect(streamer.streamId).toBe(streamId);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("DeltaStreamer.getStreamId returns the same ID on repeated calls", async () => {
|
|
89
|
+
await t.run(async (ctx) => {
|
|
90
|
+
const streamer = new DeltaStreamer(
|
|
91
|
+
components.agent,
|
|
92
|
+
ctx,
|
|
93
|
+
{ ...defaultTestOptions },
|
|
94
|
+
{ ...testMetadata, threadId },
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const id1 = await streamer.getStreamId();
|
|
98
|
+
const id2 = await streamer.getStreamId();
|
|
99
|
+
expect(id1).toBe(id2);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("Stream is created with streaming state", async () => {
|
|
104
|
+
await t.run(async (ctx) => {
|
|
105
|
+
const streamer = new DeltaStreamer(
|
|
106
|
+
components.agent,
|
|
107
|
+
ctx,
|
|
108
|
+
{ ...defaultTestOptions },
|
|
109
|
+
{ ...testMetadata, threadId },
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await streamer.getStreamId();
|
|
113
|
+
|
|
114
|
+
const streams = await ctx.runQuery(components.agent.streams.list, {
|
|
115
|
+
threadId,
|
|
116
|
+
statuses: ["streaming"],
|
|
117
|
+
});
|
|
118
|
+
expect(streams).toHaveLength(1);
|
|
119
|
+
expect(streams[0].status).toBe("streaming");
|
|
120
|
+
expect(streams[0].agentName).toBe("test agent");
|
|
121
|
+
expect(streams[0].model).toBe("test model");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("consumeStream processes full AI SDK stream to deltas", async () => {
|
|
126
|
+
await t.run(async (ctx) => {
|
|
127
|
+
const streamer = new DeltaStreamer(
|
|
128
|
+
components.agent,
|
|
129
|
+
ctx,
|
|
130
|
+
{ ...defaultTestOptions },
|
|
131
|
+
{ ...testMetadata, threadId },
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const result = streamText({
|
|
135
|
+
model: mockModel({
|
|
136
|
+
content: [{ type: "text", text: "Hello world" }],
|
|
137
|
+
}),
|
|
138
|
+
prompt: "Test",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await streamer.consumeStream(result.toUIMessageStream());
|
|
142
|
+
// Ensure the AI SDK result is also fully consumed
|
|
143
|
+
await result.consumeStream();
|
|
144
|
+
expect(streamer.streamId).toBeDefined();
|
|
145
|
+
|
|
146
|
+
// Verify deltas were saved
|
|
147
|
+
const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
|
|
148
|
+
threadId,
|
|
149
|
+
cursors: [{ cursor: 0, streamId: streamer.streamId! }],
|
|
150
|
+
});
|
|
151
|
+
expect(deltas.length).toBeGreaterThan(0);
|
|
152
|
+
|
|
153
|
+
// Verify we can reconstruct the text from deltas
|
|
154
|
+
const { parts } = getParts(deltas);
|
|
155
|
+
const textParts = parts.filter(
|
|
156
|
+
(p: any) => p.type === "text-delta",
|
|
157
|
+
);
|
|
158
|
+
expect(textParts.length).toBeGreaterThan(0);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("consumeStream transitions stream to finished state", async () => {
|
|
163
|
+
await t.run(async (ctx) => {
|
|
164
|
+
const streamer = new DeltaStreamer(
|
|
165
|
+
components.agent,
|
|
166
|
+
ctx,
|
|
167
|
+
{ ...defaultTestOptions },
|
|
168
|
+
{ ...testMetadata, threadId },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const result = streamText({
|
|
172
|
+
model: mockModel({
|
|
173
|
+
content: [{ type: "text", text: "Done" }],
|
|
174
|
+
}),
|
|
175
|
+
prompt: "Test",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await streamer.consumeStream(result.toUIMessageStream());
|
|
179
|
+
|
|
180
|
+
// Stream should now be finished
|
|
181
|
+
const streamingStreams = await ctx.runQuery(
|
|
182
|
+
components.agent.streams.list,
|
|
183
|
+
{ threadId, statuses: ["streaming"] },
|
|
184
|
+
);
|
|
185
|
+
expect(streamingStreams).toHaveLength(0);
|
|
186
|
+
|
|
187
|
+
const finishedStreams = await ctx.runQuery(
|
|
188
|
+
components.agent.streams.list,
|
|
189
|
+
{ threadId, statuses: ["finished"] },
|
|
190
|
+
);
|
|
191
|
+
expect(finishedStreams).toHaveLength(1);
|
|
192
|
+
expect(finishedStreams[0].status).toBe("finished");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("markFinishedExternally prevents consumeStream from calling finish", async () => {
|
|
197
|
+
await t.run(async (ctx) => {
|
|
198
|
+
const streamer = new DeltaStreamer(
|
|
199
|
+
components.agent,
|
|
200
|
+
ctx,
|
|
201
|
+
{ ...defaultTestOptions },
|
|
202
|
+
{ ...testMetadata, threadId },
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
await streamer.getStreamId();
|
|
206
|
+
streamer.markFinishedExternally();
|
|
207
|
+
|
|
208
|
+
const result = streamText({
|
|
209
|
+
model: mockModel({
|
|
210
|
+
content: [{ type: "text", text: "Hello" }],
|
|
211
|
+
}),
|
|
212
|
+
prompt: "Test",
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await streamer.consumeStream(result.toUIMessageStream());
|
|
216
|
+
|
|
217
|
+
// Stream should still be in streaming state since finish was skipped
|
|
218
|
+
const streamingStreams = await ctx.runQuery(
|
|
219
|
+
components.agent.streams.list,
|
|
220
|
+
{ threadId, statuses: ["streaming"] },
|
|
221
|
+
);
|
|
222
|
+
expect(streamingStreams).toHaveLength(1);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ============================================================================
|
|
228
|
+
// Stream Exclusion Logic
|
|
229
|
+
// ============================================================================
|
|
230
|
+
|
|
231
|
+
describe("Stream Exclusion Logic", () => {
|
|
232
|
+
let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
|
|
233
|
+
let threadId: string;
|
|
234
|
+
|
|
235
|
+
beforeEach(async () => {
|
|
236
|
+
t = initConvexTest();
|
|
237
|
+
await t.run(async (ctx) => {
|
|
238
|
+
threadId = await createThread(ctx, components.agent, {});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("list defaults to only streaming status", async () => {
|
|
243
|
+
await t.run(async (ctx) => {
|
|
244
|
+
// Create a stream and finish it
|
|
245
|
+
const streamer1 = new DeltaStreamer(
|
|
246
|
+
components.agent,
|
|
247
|
+
ctx,
|
|
248
|
+
{ ...defaultTestOptions },
|
|
249
|
+
{ ...testMetadata, threadId, order: 0 },
|
|
250
|
+
);
|
|
251
|
+
const r1 = streamText({
|
|
252
|
+
model: mockModel({ content: [{ type: "text", text: "Finished" }] }),
|
|
253
|
+
prompt: "Test",
|
|
254
|
+
});
|
|
255
|
+
await streamer1.consumeStream(r1.toUIMessageStream());
|
|
256
|
+
|
|
257
|
+
// Create a still-streaming stream
|
|
258
|
+
const streamer2 = new DeltaStreamer(
|
|
259
|
+
components.agent,
|
|
260
|
+
ctx,
|
|
261
|
+
{ ...defaultTestOptions },
|
|
262
|
+
{ ...testMetadata, threadId, order: 1 },
|
|
263
|
+
);
|
|
264
|
+
await streamer2.getStreamId();
|
|
265
|
+
await streamer2.addParts([{ type: "start" }]);
|
|
266
|
+
|
|
267
|
+
// Default list: only streaming
|
|
268
|
+
const defaultStreams = await ctx.runQuery(
|
|
269
|
+
components.agent.streams.list,
|
|
270
|
+
{ threadId },
|
|
271
|
+
);
|
|
272
|
+
expect(defaultStreams).toHaveLength(1);
|
|
273
|
+
expect(defaultStreams[0].status).toBe("streaming");
|
|
274
|
+
expect(defaultStreams[0].order).toBe(1);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("list with includeStatuses filters correctly", async () => {
|
|
279
|
+
await t.run(async (ctx) => {
|
|
280
|
+
// Create and finish a stream
|
|
281
|
+
const finishedStreamer = new DeltaStreamer(
|
|
282
|
+
components.agent,
|
|
283
|
+
ctx,
|
|
284
|
+
{ ...defaultTestOptions },
|
|
285
|
+
{ ...testMetadata, threadId, order: 0 },
|
|
286
|
+
);
|
|
287
|
+
const r = streamText({
|
|
288
|
+
model: mockModel({ content: [{ type: "text", text: "Done" }] }),
|
|
289
|
+
prompt: "Test",
|
|
290
|
+
});
|
|
291
|
+
await finishedStreamer.consumeStream(r.toUIMessageStream());
|
|
292
|
+
|
|
293
|
+
// Create and abort a stream
|
|
294
|
+
const abortedStreamer = new DeltaStreamer(
|
|
295
|
+
components.agent,
|
|
296
|
+
ctx,
|
|
297
|
+
{ ...defaultTestOptions },
|
|
298
|
+
{ ...testMetadata, threadId, order: 1 },
|
|
299
|
+
);
|
|
300
|
+
await abortedStreamer.getStreamId();
|
|
301
|
+
await abortedStreamer.fail("test abort");
|
|
302
|
+
|
|
303
|
+
// Create a still-streaming stream
|
|
304
|
+
const activeStreamer = new DeltaStreamer(
|
|
305
|
+
components.agent,
|
|
306
|
+
ctx,
|
|
307
|
+
{ ...defaultTestOptions },
|
|
308
|
+
{ ...testMetadata, threadId, order: 2 },
|
|
309
|
+
);
|
|
310
|
+
await activeStreamer.getStreamId();
|
|
311
|
+
|
|
312
|
+
// Query for all statuses
|
|
313
|
+
const allStreams = await ctx.runQuery(components.agent.streams.list, {
|
|
314
|
+
threadId,
|
|
315
|
+
statuses: ["streaming", "finished", "aborted"],
|
|
316
|
+
});
|
|
317
|
+
expect(allStreams).toHaveLength(3);
|
|
318
|
+
|
|
319
|
+
// Query for only finished
|
|
320
|
+
const finishedStreams = await ctx.runQuery(
|
|
321
|
+
components.agent.streams.list,
|
|
322
|
+
{ threadId, statuses: ["finished"] },
|
|
323
|
+
);
|
|
324
|
+
expect(finishedStreams).toHaveLength(1);
|
|
325
|
+
expect(finishedStreams[0].status).toBe("finished");
|
|
326
|
+
|
|
327
|
+
// Query for only aborted
|
|
328
|
+
const abortedStreams = await ctx.runQuery(
|
|
329
|
+
components.agent.streams.list,
|
|
330
|
+
{ threadId, statuses: ["aborted"] },
|
|
331
|
+
);
|
|
332
|
+
expect(abortedStreams).toHaveLength(1);
|
|
333
|
+
expect(abortedStreams[0].status).toBe("aborted");
|
|
334
|
+
|
|
335
|
+
// Query for streaming + aborted
|
|
336
|
+
const streamingAndAborted = await ctx.runQuery(
|
|
337
|
+
components.agent.streams.list,
|
|
338
|
+
{ threadId, statuses: ["streaming", "aborted"] },
|
|
339
|
+
);
|
|
340
|
+
expect(streamingAndAborted).toHaveLength(2);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("startOrder filters out streams with lower order", async () => {
|
|
345
|
+
await t.run(async (ctx) => {
|
|
346
|
+
// Create streams at different orders
|
|
347
|
+
for (const order of [0, 1, 2, 3]) {
|
|
348
|
+
const streamer = new DeltaStreamer(
|
|
349
|
+
components.agent,
|
|
350
|
+
ctx,
|
|
351
|
+
{ ...defaultTestOptions },
|
|
352
|
+
{ ...testMetadata, threadId, order },
|
|
353
|
+
);
|
|
354
|
+
await streamer.getStreamId();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// startOrder=2 should only return streams with order >= 2
|
|
358
|
+
const filtered = await ctx.runQuery(components.agent.streams.list, {
|
|
359
|
+
threadId,
|
|
360
|
+
startOrder: 2,
|
|
361
|
+
statuses: ["streaming"],
|
|
362
|
+
});
|
|
363
|
+
expect(filtered).toHaveLength(2);
|
|
364
|
+
expect(filtered.every((s) => s.order >= 2)).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("streams from different threads are isolated", async () => {
|
|
369
|
+
let threadId2: string;
|
|
370
|
+
await t.run(async (ctx) => {
|
|
371
|
+
threadId2 = await createThread(ctx, components.agent, {});
|
|
372
|
+
|
|
373
|
+
// Create a stream in thread 1
|
|
374
|
+
const s1 = new DeltaStreamer(
|
|
375
|
+
components.agent,
|
|
376
|
+
ctx,
|
|
377
|
+
{ ...defaultTestOptions },
|
|
378
|
+
{ ...testMetadata, threadId, order: 0 },
|
|
379
|
+
);
|
|
380
|
+
await s1.getStreamId();
|
|
381
|
+
|
|
382
|
+
// Create a stream in thread 2
|
|
383
|
+
const s2 = new DeltaStreamer(
|
|
384
|
+
components.agent,
|
|
385
|
+
ctx,
|
|
386
|
+
{ ...defaultTestOptions },
|
|
387
|
+
{ ...testMetadata, threadId: threadId2, order: 0 },
|
|
388
|
+
);
|
|
389
|
+
await s2.getStreamId();
|
|
390
|
+
|
|
391
|
+
// Each thread should only see its own streams
|
|
392
|
+
const t1Streams = await ctx.runQuery(components.agent.streams.list, {
|
|
393
|
+
threadId,
|
|
394
|
+
statuses: ["streaming"],
|
|
395
|
+
});
|
|
396
|
+
expect(t1Streams).toHaveLength(1);
|
|
397
|
+
|
|
398
|
+
const t2Streams = await ctx.runQuery(components.agent.streams.list, {
|
|
399
|
+
threadId: threadId2,
|
|
400
|
+
statuses: ["streaming"],
|
|
401
|
+
});
|
|
402
|
+
expect(t2Streams).toHaveLength(1);
|
|
403
|
+
|
|
404
|
+
expect(t1Streams[0].streamId).not.toBe(t2Streams[0].streamId);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("dedupeMessages prefers finalized over streaming over pending", () => {
|
|
409
|
+
type M = {
|
|
410
|
+
order: number;
|
|
411
|
+
stepOrder: number;
|
|
412
|
+
status: "pending" | "success" | "failed" | "streaming";
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const messages: M[] = [
|
|
416
|
+
{ order: 1, stepOrder: 0, status: "pending" },
|
|
417
|
+
{ order: 2, stepOrder: 0, status: "success" },
|
|
418
|
+
{ order: 3, stepOrder: 0, status: "pending" },
|
|
419
|
+
];
|
|
420
|
+
const streamMessages: M[] = [
|
|
421
|
+
{ order: 1, stepOrder: 0, status: "streaming" },
|
|
422
|
+
{ order: 2, stepOrder: 0, status: "streaming" },
|
|
423
|
+
{ order: 3, stepOrder: 0, status: "success" },
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
const result = dedupeMessages(messages, streamMessages);
|
|
427
|
+
expect(result).toHaveLength(3);
|
|
428
|
+
// pending replaced by streaming
|
|
429
|
+
expect(result[0].status).toBe("streaming");
|
|
430
|
+
// success kept over streaming
|
|
431
|
+
expect(result[1].status).toBe("success");
|
|
432
|
+
// pending replaced by success
|
|
433
|
+
expect(result[2].status).toBe("success");
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// ============================================================================
|
|
438
|
+
// Delta Stream Consumption
|
|
439
|
+
// ============================================================================
|
|
440
|
+
|
|
441
|
+
describe("Delta Stream Consumption", () => {
|
|
442
|
+
let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
|
|
443
|
+
let threadId: string;
|
|
444
|
+
|
|
445
|
+
beforeEach(async () => {
|
|
446
|
+
t = initConvexTest();
|
|
447
|
+
await t.run(async (ctx) => {
|
|
448
|
+
threadId = await createThread(ctx, components.agent, {});
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("cursor-based incremental delta fetching", async () => {
|
|
453
|
+
await t.run(async (ctx) => {
|
|
454
|
+
const streamer = new DeltaStreamer(
|
|
455
|
+
components.agent,
|
|
456
|
+
ctx,
|
|
457
|
+
{ ...defaultTestOptions },
|
|
458
|
+
{ ...testMetadata, threadId },
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
const result = streamText({
|
|
462
|
+
model: mockModel({
|
|
463
|
+
content: [{ type: "text", text: "One Two Three Four" }],
|
|
464
|
+
}),
|
|
465
|
+
prompt: "Test",
|
|
466
|
+
});
|
|
467
|
+
await streamer.consumeStream(result.toUIMessageStream());
|
|
468
|
+
const streamId = streamer.streamId!;
|
|
469
|
+
|
|
470
|
+
// Fetch all deltas from start
|
|
471
|
+
const allDeltas = await ctx.runQuery(
|
|
472
|
+
components.agent.streams.listDeltas,
|
|
473
|
+
{ threadId, cursors: [{ cursor: 0, streamId }] },
|
|
474
|
+
);
|
|
475
|
+
expect(allDeltas.length).toBeGreaterThan(0);
|
|
476
|
+
const { parts: allParts, cursor: endCursor } = getParts(allDeltas);
|
|
477
|
+
|
|
478
|
+
// Fetch from midpoint cursor - should only get remaining deltas
|
|
479
|
+
const midCursor = Math.floor(endCursor / 2);
|
|
480
|
+
const laterDeltas = await ctx.runQuery(
|
|
481
|
+
components.agent.streams.listDeltas,
|
|
482
|
+
{ threadId, cursors: [{ cursor: midCursor, streamId }] },
|
|
483
|
+
);
|
|
484
|
+
const { parts: laterParts } = getParts(laterDeltas, midCursor);
|
|
485
|
+
|
|
486
|
+
// Later parts should be a subset of all parts
|
|
487
|
+
expect(laterParts.length).toBeLessThanOrEqual(allParts.length);
|
|
488
|
+
|
|
489
|
+
// Fetching from the end cursor should yield nothing
|
|
490
|
+
const noDeltas = await ctx.runQuery(
|
|
491
|
+
components.agent.streams.listDeltas,
|
|
492
|
+
{ threadId, cursors: [{ cursor: endCursor, streamId }] },
|
|
493
|
+
);
|
|
494
|
+
expect(noDeltas).toHaveLength(0);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("multi-stream delta fetching with separate cursors", async () => {
|
|
499
|
+
await t.run(async (ctx) => {
|
|
500
|
+
// Create two streams with different content
|
|
501
|
+
const streamer1 = new DeltaStreamer(
|
|
502
|
+
components.agent,
|
|
503
|
+
ctx,
|
|
504
|
+
{ ...defaultTestOptions },
|
|
505
|
+
{ ...testMetadata, threadId, order: 0 },
|
|
506
|
+
);
|
|
507
|
+
const r1 = streamText({
|
|
508
|
+
model: mockModel({ content: [{ type: "text", text: "Stream One" }] }),
|
|
509
|
+
prompt: "Test 1",
|
|
510
|
+
});
|
|
511
|
+
await streamer1.consumeStream(r1.toUIMessageStream());
|
|
512
|
+
|
|
513
|
+
const streamer2 = new DeltaStreamer(
|
|
514
|
+
components.agent,
|
|
515
|
+
ctx,
|
|
516
|
+
{ ...defaultTestOptions },
|
|
517
|
+
{ ...testMetadata, threadId, order: 1 },
|
|
518
|
+
);
|
|
519
|
+
const r2 = streamText({
|
|
520
|
+
model: mockModel({ content: [{ type: "text", text: "Stream Two" }] }),
|
|
521
|
+
prompt: "Test 2",
|
|
522
|
+
});
|
|
523
|
+
await streamer2.consumeStream(r2.toUIMessageStream());
|
|
524
|
+
|
|
525
|
+
const id1 = streamer1.streamId!;
|
|
526
|
+
const id2 = streamer2.streamId!;
|
|
527
|
+
|
|
528
|
+
// Fetch deltas for both streams simultaneously
|
|
529
|
+
const deltas = await ctx.runQuery(
|
|
530
|
+
components.agent.streams.listDeltas,
|
|
531
|
+
{
|
|
532
|
+
threadId,
|
|
533
|
+
cursors: [
|
|
534
|
+
{ cursor: 0, streamId: id1 },
|
|
535
|
+
{ cursor: 0, streamId: id2 },
|
|
536
|
+
],
|
|
537
|
+
},
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
// Should have deltas for both streams
|
|
541
|
+
const s1Deltas = deltas.filter((d) => d.streamId === id1);
|
|
542
|
+
const s2Deltas = deltas.filter((d) => d.streamId === id2);
|
|
543
|
+
expect(s1Deltas.length).toBeGreaterThan(0);
|
|
544
|
+
expect(s2Deltas.length).toBeGreaterThan(0);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("deriveUIMessagesFromDeltas reconstructs messages from UIMessageChunk format", async () => {
|
|
549
|
+
await t.run(async (ctx) => {
|
|
550
|
+
const streamer = new DeltaStreamer(
|
|
551
|
+
components.agent,
|
|
552
|
+
ctx,
|
|
553
|
+
{ ...defaultTestOptions },
|
|
554
|
+
{ ...testMetadata, threadId },
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
const result = streamText({
|
|
558
|
+
model: mockModel({
|
|
559
|
+
content: [{ type: "text", text: "Hello from deltas" }],
|
|
560
|
+
}),
|
|
561
|
+
prompt: "Test",
|
|
562
|
+
});
|
|
563
|
+
await streamer.consumeStream(result.toUIMessageStream());
|
|
564
|
+
const streamId = streamer.streamId!;
|
|
565
|
+
|
|
566
|
+
// Fetch stream messages and deltas
|
|
567
|
+
const streams = await ctx.runQuery(components.agent.streams.list, {
|
|
568
|
+
threadId,
|
|
569
|
+
statuses: ["finished"],
|
|
570
|
+
});
|
|
571
|
+
const deltas = await ctx.runQuery(
|
|
572
|
+
components.agent.streams.listDeltas,
|
|
573
|
+
{ threadId, cursors: [{ cursor: 0, streamId }] },
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// Derive UI messages
|
|
577
|
+
const uiMessages = await deriveUIMessagesFromDeltas(
|
|
578
|
+
threadId,
|
|
579
|
+
streams,
|
|
580
|
+
deltas,
|
|
581
|
+
);
|
|
582
|
+
expect(uiMessages).toHaveLength(1);
|
|
583
|
+
expect(uiMessages[0].role).toBe("assistant");
|
|
584
|
+
expect(uiMessages[0].text).toContain("Hello");
|
|
585
|
+
expect(uiMessages[0].text).toContain("from");
|
|
586
|
+
expect(uiMessages[0].text).toContain("deltas");
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("compression merges consecutive text deltas", async () => {
|
|
591
|
+
await t.run(async (ctx) => {
|
|
592
|
+
const streamer = new DeltaStreamer(
|
|
593
|
+
components.agent,
|
|
594
|
+
ctx,
|
|
595
|
+
{
|
|
596
|
+
throttleMs: 1000,
|
|
597
|
+
abortSignal: undefined,
|
|
598
|
+
compress: compressUIMessageChunks,
|
|
599
|
+
onAsyncAbort: async () => {
|
|
600
|
+
throw new Error("async abort");
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
{ ...testMetadata, threadId },
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
const result = streamText({
|
|
607
|
+
model: mockModel({
|
|
608
|
+
content: [
|
|
609
|
+
{ type: "text", text: "A B C" },
|
|
610
|
+
{ type: "reasoning", text: "X Y Z" },
|
|
611
|
+
],
|
|
612
|
+
}),
|
|
613
|
+
prompt: "Test",
|
|
614
|
+
});
|
|
615
|
+
await streamer.consumeStream(result.toUIMessageStream());
|
|
616
|
+
const streamId = streamer.streamId!;
|
|
617
|
+
|
|
618
|
+
const deltas = await ctx.runQuery(
|
|
619
|
+
components.agent.streams.listDeltas,
|
|
620
|
+
{ threadId, cursors: [{ cursor: 0, streamId }] },
|
|
621
|
+
);
|
|
622
|
+
const { parts } = getParts(deltas);
|
|
623
|
+
|
|
624
|
+
// Compressed: all text-deltas for one text section should be merged
|
|
625
|
+
const textDeltas = parts.filter((p: any) => p.type === "text-delta");
|
|
626
|
+
// With compression and throttleMs=1000, text deltas should be merged
|
|
627
|
+
expect(textDeltas.length).toBeLessThanOrEqual(1);
|
|
628
|
+
if (textDeltas.length === 1) {
|
|
629
|
+
expect((textDeltas[0] as { delta: string }).delta).toBe("A B C");
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Reasoning deltas should also be merged
|
|
633
|
+
const reasoningDeltas = parts.filter(
|
|
634
|
+
(p: any) => p.type === "reasoning-delta",
|
|
635
|
+
);
|
|
636
|
+
expect(reasoningDeltas.length).toBeLessThanOrEqual(1);
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("getParts validates delta continuity", () => {
|
|
641
|
+
const streamId = "test-stream";
|
|
642
|
+
|
|
643
|
+
// Normal continuous deltas
|
|
644
|
+
const deltas: StreamDelta[] = [
|
|
645
|
+
{ streamId, start: 0, end: 3, parts: [{ type: "text-delta" }] },
|
|
646
|
+
{ streamId, start: 3, end: 6, parts: [{ type: "text-delta" }] },
|
|
647
|
+
{ streamId, start: 6, end: 9, parts: [{ type: "text-delta" }] },
|
|
648
|
+
];
|
|
649
|
+
const { parts, cursor } = getParts(deltas);
|
|
650
|
+
expect(parts).toHaveLength(3);
|
|
651
|
+
expect(cursor).toBe(9);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("getParts handles gap in deltas gracefully", () => {
|
|
655
|
+
const streamId = "test-stream";
|
|
656
|
+
|
|
657
|
+
// Deltas with a gap (missing 3-6)
|
|
658
|
+
const deltas: StreamDelta[] = [
|
|
659
|
+
{ streamId, start: 0, end: 3, parts: [{ type: "a" }] },
|
|
660
|
+
{ streamId, start: 6, end: 9, parts: [{ type: "b" }] },
|
|
661
|
+
];
|
|
662
|
+
const { parts, cursor } = getParts(deltas);
|
|
663
|
+
// Should stop at the gap
|
|
664
|
+
expect(parts).toHaveLength(1);
|
|
665
|
+
expect(cursor).toBe(3);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("getParts skips already-consumed deltas", () => {
|
|
669
|
+
const streamId = "test-stream";
|
|
670
|
+
const deltas: StreamDelta[] = [
|
|
671
|
+
{ streamId, start: 0, end: 3, parts: [{ type: "old" }] },
|
|
672
|
+
{ streamId, start: 3, end: 6, parts: [{ type: "new" }] },
|
|
673
|
+
];
|
|
674
|
+
// Start from cursor=3 to skip first delta
|
|
675
|
+
const { parts, cursor } = getParts(deltas, 3);
|
|
676
|
+
expect(parts).toHaveLength(1);
|
|
677
|
+
expect((parts[0] as { type: string }).type).toBe("new");
|
|
678
|
+
expect(cursor).toBe(6);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("TextStreamPart format delta reconstruction with tool calls", () => {
|
|
682
|
+
const streamId = "s1";
|
|
683
|
+
const streamMessage: StreamMessage = {
|
|
684
|
+
streamId,
|
|
685
|
+
order: 1,
|
|
686
|
+
stepOrder: 0,
|
|
687
|
+
status: "streaming",
|
|
688
|
+
};
|
|
689
|
+
const deltas: StreamDelta[] = [
|
|
690
|
+
{
|
|
691
|
+
streamId,
|
|
692
|
+
start: 0,
|
|
693
|
+
end: 1,
|
|
694
|
+
parts: [{ type: "text-delta", id: "txt-0", text: "Let me call a tool. " }],
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
streamId,
|
|
698
|
+
start: 1,
|
|
699
|
+
end: 2,
|
|
700
|
+
parts: [
|
|
701
|
+
{
|
|
702
|
+
type: "tool-call",
|
|
703
|
+
toolCallId: "tc1",
|
|
704
|
+
toolName: "search",
|
|
705
|
+
input: { query: "hello" },
|
|
706
|
+
},
|
|
707
|
+
],
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
streamId,
|
|
711
|
+
start: 2,
|
|
712
|
+
end: 3,
|
|
713
|
+
parts: [
|
|
714
|
+
{
|
|
715
|
+
type: "tool-result",
|
|
716
|
+
toolCallId: "tc1",
|
|
717
|
+
toolName: "search",
|
|
718
|
+
output: "Found 3 results",
|
|
719
|
+
},
|
|
720
|
+
],
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
streamId,
|
|
724
|
+
start: 3,
|
|
725
|
+
end: 4,
|
|
726
|
+
parts: [
|
|
727
|
+
{ type: "text-delta", id: "txt-1", text: "Here are the results." },
|
|
728
|
+
],
|
|
729
|
+
},
|
|
730
|
+
];
|
|
731
|
+
|
|
732
|
+
const [messages, , changed] = deriveUIMessagesFromTextStreamParts(
|
|
733
|
+
"thread1",
|
|
734
|
+
[streamMessage],
|
|
735
|
+
[],
|
|
736
|
+
deltas,
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
expect(messages).toHaveLength(1);
|
|
740
|
+
expect(changed).toBe(true);
|
|
741
|
+
|
|
742
|
+
const msg = messages[0];
|
|
743
|
+
expect(msg.text).toContain("Let me call a tool.");
|
|
744
|
+
expect(msg.text).toContain("Here are the results.");
|
|
745
|
+
|
|
746
|
+
const toolParts = msg.parts.filter((p: any) =>
|
|
747
|
+
p.type.startsWith("tool-"),
|
|
748
|
+
);
|
|
749
|
+
expect(toolParts.length).toBeGreaterThan(0);
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// ============================================================================
|
|
754
|
+
// Fallback Behavior between HTTP and Delta Streams
|
|
755
|
+
// ============================================================================
|
|
756
|
+
|
|
757
|
+
describe("Fallback Behavior", () => {
|
|
758
|
+
let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
|
|
759
|
+
let threadId: string;
|
|
760
|
+
|
|
761
|
+
beforeEach(async () => {
|
|
762
|
+
t = initConvexTest();
|
|
763
|
+
await t.run(async (ctx) => {
|
|
764
|
+
threadId = await createThread(ctx, components.agent, {});
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("aborted stream transitions to aborted state", async () => {
|
|
769
|
+
await t.run(async (ctx) => {
|
|
770
|
+
const streamer = new DeltaStreamer(
|
|
771
|
+
components.agent,
|
|
772
|
+
ctx,
|
|
773
|
+
{ ...defaultTestOptions },
|
|
774
|
+
{ ...testMetadata, threadId },
|
|
775
|
+
);
|
|
776
|
+
await streamer.getStreamId();
|
|
777
|
+
|
|
778
|
+
await streamer.fail("User canceled");
|
|
779
|
+
|
|
780
|
+
const aborted = await ctx.runQuery(components.agent.streams.list, {
|
|
781
|
+
threadId,
|
|
782
|
+
statuses: ["aborted"],
|
|
783
|
+
});
|
|
784
|
+
expect(aborted).toHaveLength(1);
|
|
785
|
+
expect(aborted[0].status).toBe("aborted");
|
|
786
|
+
|
|
787
|
+
// No streaming streams left
|
|
788
|
+
const streaming = await ctx.runQuery(components.agent.streams.list, {
|
|
789
|
+
threadId,
|
|
790
|
+
statuses: ["streaming"],
|
|
791
|
+
});
|
|
792
|
+
expect(streaming).toHaveLength(0);
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("abort via abortByOrder aborts all streams at that order", async () => {
|
|
797
|
+
await t.run(async (ctx) => {
|
|
798
|
+
// Create two streams at the same order (different stepOrders)
|
|
799
|
+
const s1 = new DeltaStreamer(
|
|
800
|
+
components.agent,
|
|
801
|
+
ctx,
|
|
802
|
+
{ ...defaultTestOptions },
|
|
803
|
+
{ ...testMetadata, threadId, order: 5, stepOrder: 0 },
|
|
804
|
+
);
|
|
805
|
+
await s1.getStreamId();
|
|
806
|
+
|
|
807
|
+
const s2 = new DeltaStreamer(
|
|
808
|
+
components.agent,
|
|
809
|
+
ctx,
|
|
810
|
+
{ ...defaultTestOptions },
|
|
811
|
+
{ ...testMetadata, threadId, order: 5, stepOrder: 1 },
|
|
812
|
+
);
|
|
813
|
+
await s2.getStreamId();
|
|
814
|
+
|
|
815
|
+
// Abort by order
|
|
816
|
+
const result = await ctx.runMutation(
|
|
817
|
+
components.agent.streams.abortByOrder,
|
|
818
|
+
{ threadId, order: 5, reason: "batch abort" },
|
|
819
|
+
);
|
|
820
|
+
expect(result).toBe(true);
|
|
821
|
+
|
|
822
|
+
const streaming = await ctx.runQuery(components.agent.streams.list, {
|
|
823
|
+
threadId,
|
|
824
|
+
statuses: ["streaming"],
|
|
825
|
+
});
|
|
826
|
+
expect(streaming).toHaveLength(0);
|
|
827
|
+
|
|
828
|
+
const aborted = await ctx.runQuery(components.agent.streams.list, {
|
|
829
|
+
threadId,
|
|
830
|
+
statuses: ["aborted"],
|
|
831
|
+
});
|
|
832
|
+
expect(aborted).toHaveLength(2);
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("fail on already-aborted stream is a no-op", async () => {
|
|
837
|
+
await t.run(async (ctx) => {
|
|
838
|
+
const streamer = new DeltaStreamer(
|
|
839
|
+
components.agent,
|
|
840
|
+
ctx,
|
|
841
|
+
{ ...defaultTestOptions },
|
|
842
|
+
{ ...testMetadata, threadId },
|
|
843
|
+
);
|
|
844
|
+
await streamer.getStreamId();
|
|
845
|
+
|
|
846
|
+
// First abort
|
|
847
|
+
await streamer.fail("First abort");
|
|
848
|
+
|
|
849
|
+
// Second abort is a no-op (no error thrown)
|
|
850
|
+
await streamer.fail("Second abort");
|
|
851
|
+
|
|
852
|
+
const aborted = await ctx.runQuery(components.agent.streams.list, {
|
|
853
|
+
threadId,
|
|
854
|
+
statuses: ["aborted"],
|
|
855
|
+
});
|
|
856
|
+
expect(aborted).toHaveLength(1);
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test("finish on non-existent stream is a no-op", async () => {
|
|
861
|
+
await t.run(async (ctx) => {
|
|
862
|
+
const streamer = new DeltaStreamer(
|
|
863
|
+
components.agent,
|
|
864
|
+
ctx,
|
|
865
|
+
{ ...defaultTestOptions },
|
|
866
|
+
{ ...testMetadata, threadId },
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
// Calling finish without ever creating a stream should be safe
|
|
870
|
+
await streamer.finish();
|
|
871
|
+
expect(streamer.streamId).toBeUndefined();
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test("deriveUIMessagesFromDeltas maps stream status correctly", async () => {
|
|
876
|
+
// Streaming status
|
|
877
|
+
const streamingMsg: StreamMessage = {
|
|
878
|
+
streamId: "s1",
|
|
879
|
+
order: 0,
|
|
880
|
+
stepOrder: 0,
|
|
881
|
+
status: "streaming",
|
|
882
|
+
};
|
|
883
|
+
const finishedMsg: StreamMessage = {
|
|
884
|
+
streamId: "s2",
|
|
885
|
+
order: 1,
|
|
886
|
+
stepOrder: 0,
|
|
887
|
+
status: "finished",
|
|
888
|
+
};
|
|
889
|
+
const abortedMsg: StreamMessage = {
|
|
890
|
+
streamId: "s3",
|
|
891
|
+
order: 2,
|
|
892
|
+
stepOrder: 0,
|
|
893
|
+
status: "aborted",
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const msgs = await deriveUIMessagesFromDeltas(
|
|
897
|
+
"t1",
|
|
898
|
+
[streamingMsg, finishedMsg, abortedMsg],
|
|
899
|
+
[],
|
|
900
|
+
);
|
|
901
|
+
expect(msgs[0].status).toBe("streaming");
|
|
902
|
+
expect(msgs[1].status).toBe("success");
|
|
903
|
+
expect(msgs[2].status).toBe("failed");
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
test("dedupeMessages handles fallback from streaming to finalized gracefully", () => {
|
|
907
|
+
type M = {
|
|
908
|
+
order: number;
|
|
909
|
+
stepOrder: number;
|
|
910
|
+
status: "pending" | "success" | "failed" | "streaming";
|
|
911
|
+
text: string;
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
// Simulate: full messages from DB include finalized versions, streaming
|
|
915
|
+
// messages are still around from the delta stream
|
|
916
|
+
const dbMessages: M[] = [
|
|
917
|
+
{ order: 1, stepOrder: 0, status: "success", text: "Final answer" },
|
|
918
|
+
{ order: 2, stepOrder: 0, status: "pending", text: "Thinking..." },
|
|
919
|
+
];
|
|
920
|
+
const streamMessages: M[] = [
|
|
921
|
+
{ order: 1, stepOrder: 0, status: "streaming", text: "Final ans..." },
|
|
922
|
+
{ order: 2, stepOrder: 0, status: "streaming", text: "Thinking..." },
|
|
923
|
+
];
|
|
924
|
+
|
|
925
|
+
const result = dedupeMessages(dbMessages, streamMessages);
|
|
926
|
+
|
|
927
|
+
// Order 1: finalized DB version preferred over streaming
|
|
928
|
+
expect(result[0].status).toBe("success");
|
|
929
|
+
expect(result[0].text).toBe("Final answer");
|
|
930
|
+
|
|
931
|
+
// Order 2: streaming preferred over pending DB version
|
|
932
|
+
expect(result[1].status).toBe("streaming");
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test("mergeTransforms adds smoothStream when streaming is enabled", () => {
|
|
936
|
+
// No streaming options - returns existing transforms
|
|
937
|
+
expect(mergeTransforms(undefined, undefined)).toBeUndefined();
|
|
938
|
+
|
|
939
|
+
// Boolean true - adds smoothStream
|
|
940
|
+
const transforms = mergeTransforms(true, undefined);
|
|
941
|
+
expect(transforms).toBeDefined();
|
|
942
|
+
expect(Array.isArray(transforms)).toBe(true);
|
|
943
|
+
expect((transforms as any[]).length).toBe(1);
|
|
944
|
+
|
|
945
|
+
// With existing transforms - appends
|
|
946
|
+
const existing = [(chunk: any) => chunk];
|
|
947
|
+
const merged = mergeTransforms(true, existing);
|
|
948
|
+
expect(Array.isArray(merged)).toBe(true);
|
|
949
|
+
expect((merged as any[]).length).toBe(2);
|
|
950
|
+
|
|
951
|
+
// Custom chunking
|
|
952
|
+
const custom = mergeTransforms({ chunking: "word" }, undefined);
|
|
953
|
+
expect(custom).toBeDefined();
|
|
954
|
+
expect(Array.isArray(custom)).toBe(true);
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
// ============================================================================
|
|
959
|
+
// Stream Lifecycle Integration
|
|
960
|
+
// ============================================================================
|
|
961
|
+
|
|
962
|
+
describe("Stream Lifecycle Integration", () => {
|
|
963
|
+
let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
|
|
964
|
+
let threadId: string;
|
|
965
|
+
|
|
966
|
+
beforeEach(async () => {
|
|
967
|
+
t = initConvexTest();
|
|
968
|
+
await t.run(async (ctx) => {
|
|
969
|
+
threadId = await createThread(ctx, components.agent, {});
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
test("full lifecycle: create -> stream -> finish -> derive messages", async () => {
|
|
974
|
+
await t.run(async (ctx) => {
|
|
975
|
+
// 1. Create the stream
|
|
976
|
+
const streamer = new DeltaStreamer(
|
|
977
|
+
components.agent,
|
|
978
|
+
ctx,
|
|
979
|
+
{ ...defaultTestOptions },
|
|
980
|
+
{ ...testMetadata, threadId },
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
// 2. Stream content
|
|
984
|
+
const result = streamText({
|
|
985
|
+
model: mockModel({
|
|
986
|
+
content: [
|
|
987
|
+
{ type: "text", text: "Once upon a time" },
|
|
988
|
+
{ type: "reasoning", text: "I should tell a story" },
|
|
989
|
+
],
|
|
990
|
+
}),
|
|
991
|
+
prompt: "Tell me a story",
|
|
992
|
+
});
|
|
993
|
+
await streamer.consumeStream(result.toUIMessageStream());
|
|
994
|
+
const streamId = streamer.streamId!;
|
|
995
|
+
|
|
996
|
+
// 3. Verify finish state
|
|
997
|
+
const finished = await ctx.runQuery(components.agent.streams.list, {
|
|
998
|
+
threadId,
|
|
999
|
+
statuses: ["finished"],
|
|
1000
|
+
});
|
|
1001
|
+
expect(finished).toHaveLength(1);
|
|
1002
|
+
|
|
1003
|
+
// 4. Derive UI messages from stored deltas
|
|
1004
|
+
const deltas = await ctx.runQuery(
|
|
1005
|
+
components.agent.streams.listDeltas,
|
|
1006
|
+
{ threadId, cursors: [{ cursor: 0, streamId }] },
|
|
1007
|
+
);
|
|
1008
|
+
const uiMessages = await deriveUIMessagesFromDeltas(
|
|
1009
|
+
threadId,
|
|
1010
|
+
finished,
|
|
1011
|
+
deltas,
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
expect(uiMessages).toHaveLength(1);
|
|
1015
|
+
const msg = uiMessages[0];
|
|
1016
|
+
expect(msg.role).toBe("assistant");
|
|
1017
|
+
expect(msg.text).toContain("Once");
|
|
1018
|
+
expect(msg.text).toContain("upon");
|
|
1019
|
+
expect(msg.text).toContain("time");
|
|
1020
|
+
expect(msg.status).toBe("success");
|
|
1021
|
+
|
|
1022
|
+
// Check that reasoning parts are present
|
|
1023
|
+
const reasoningParts = msg.parts.filter(
|
|
1024
|
+
(p: any) => p.type === "reasoning",
|
|
1025
|
+
);
|
|
1026
|
+
expect(reasoningParts.length).toBeGreaterThan(0);
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test("full lifecycle: create -> partial stream -> abort -> derive aborted messages", async () => {
|
|
1031
|
+
await t.run(async (ctx) => {
|
|
1032
|
+
const streamer = new DeltaStreamer(
|
|
1033
|
+
components.agent,
|
|
1034
|
+
ctx,
|
|
1035
|
+
{ ...defaultTestOptions },
|
|
1036
|
+
{ ...testMetadata, threadId },
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
// Stream some content then abort
|
|
1040
|
+
await streamer.addParts([
|
|
1041
|
+
{ type: "start" },
|
|
1042
|
+
{ type: "start-step" },
|
|
1043
|
+
{ type: "text-start", id: "txt-0" },
|
|
1044
|
+
{ type: "text-delta", id: "txt-0", delta: "Partial" },
|
|
1045
|
+
]);
|
|
1046
|
+
await streamer.fail("User aborted");
|
|
1047
|
+
|
|
1048
|
+
const streamId = streamer.streamId!;
|
|
1049
|
+
|
|
1050
|
+
// Verify aborted state
|
|
1051
|
+
const aborted = await ctx.runQuery(components.agent.streams.list, {
|
|
1052
|
+
threadId,
|
|
1053
|
+
statuses: ["aborted"],
|
|
1054
|
+
});
|
|
1055
|
+
expect(aborted).toHaveLength(1);
|
|
1056
|
+
expect(aborted[0].status).toBe("aborted");
|
|
1057
|
+
|
|
1058
|
+
// Even aborted streams have their deltas stored
|
|
1059
|
+
const deltas = await ctx.runQuery(
|
|
1060
|
+
components.agent.streams.listDeltas,
|
|
1061
|
+
{ threadId, cursors: [{ cursor: 0, streamId }] },
|
|
1062
|
+
);
|
|
1063
|
+
expect(deltas.length).toBeGreaterThan(0);
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
test("multiple concurrent streams in same thread", async () => {
|
|
1068
|
+
await t.run(async (ctx) => {
|
|
1069
|
+
const streamers = [];
|
|
1070
|
+
for (let i = 0; i < 3; i++) {
|
|
1071
|
+
const streamer = new DeltaStreamer(
|
|
1072
|
+
components.agent,
|
|
1073
|
+
ctx,
|
|
1074
|
+
{ ...defaultTestOptions },
|
|
1075
|
+
{ ...testMetadata, threadId, order: i },
|
|
1076
|
+
);
|
|
1077
|
+
const r = streamText({
|
|
1078
|
+
model: mockModel({
|
|
1079
|
+
content: [{ type: "text", text: `Message ${i}` }],
|
|
1080
|
+
}),
|
|
1081
|
+
prompt: "Test",
|
|
1082
|
+
});
|
|
1083
|
+
await streamer.consumeStream(r.toUIMessageStream());
|
|
1084
|
+
streamers.push(streamer);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// All should be finished
|
|
1088
|
+
const finished = await ctx.runQuery(components.agent.streams.list, {
|
|
1089
|
+
threadId,
|
|
1090
|
+
statuses: ["finished"],
|
|
1091
|
+
});
|
|
1092
|
+
expect(finished).toHaveLength(3);
|
|
1093
|
+
|
|
1094
|
+
// Derive all messages
|
|
1095
|
+
const allDeltas = await ctx.runQuery(
|
|
1096
|
+
components.agent.streams.listDeltas,
|
|
1097
|
+
{
|
|
1098
|
+
threadId,
|
|
1099
|
+
cursors: streamers.map((s) => ({
|
|
1100
|
+
cursor: 0,
|
|
1101
|
+
streamId: s.streamId!,
|
|
1102
|
+
})),
|
|
1103
|
+
},
|
|
1104
|
+
);
|
|
1105
|
+
const uiMessages = await deriveUIMessagesFromDeltas(
|
|
1106
|
+
threadId,
|
|
1107
|
+
finished,
|
|
1108
|
+
allDeltas,
|
|
1109
|
+
);
|
|
1110
|
+
expect(uiMessages).toHaveLength(3);
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
test("stream deletion removes both stream and its deltas", async () => {
|
|
1115
|
+
await t.run(async (ctx) => {
|
|
1116
|
+
const streamer = new DeltaStreamer(
|
|
1117
|
+
components.agent,
|
|
1118
|
+
ctx,
|
|
1119
|
+
{ ...defaultTestOptions },
|
|
1120
|
+
{ ...testMetadata, threadId },
|
|
1121
|
+
);
|
|
1122
|
+
const r = streamText({
|
|
1123
|
+
model: mockModel({ content: [{ type: "text", text: "Delete me" }] }),
|
|
1124
|
+
prompt: "Test",
|
|
1125
|
+
});
|
|
1126
|
+
await streamer.consumeStream(r.toUIMessageStream());
|
|
1127
|
+
const streamId = streamer.streamId!;
|
|
1128
|
+
|
|
1129
|
+
// Verify deltas exist
|
|
1130
|
+
const beforeDeltas = await ctx.runQuery(
|
|
1131
|
+
components.agent.streams.listDeltas,
|
|
1132
|
+
{ threadId, cursors: [{ cursor: 0, streamId }] },
|
|
1133
|
+
);
|
|
1134
|
+
expect(beforeDeltas.length).toBeGreaterThan(0);
|
|
1135
|
+
|
|
1136
|
+
// Delete the stream
|
|
1137
|
+
await ctx.runMutation(components.agent.streams.deleteStreamSync, {
|
|
1138
|
+
streamId,
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
// Both stream and deltas should be gone
|
|
1142
|
+
const afterStreams = await ctx.runQuery(components.agent.streams.list, {
|
|
1143
|
+
threadId,
|
|
1144
|
+
statuses: ["streaming", "finished", "aborted"],
|
|
1145
|
+
});
|
|
1146
|
+
expect(afterStreams).toHaveLength(0);
|
|
1147
|
+
|
|
1148
|
+
const afterDeltas = await ctx.runQuery(
|
|
1149
|
+
components.agent.streams.listDeltas,
|
|
1150
|
+
{ threadId, cursors: [{ cursor: 0, streamId }] },
|
|
1151
|
+
);
|
|
1152
|
+
expect(afterDeltas).toHaveLength(0);
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
// ============================================================================
|
|
1158
|
+
// Compression
|
|
1159
|
+
// ============================================================================
|
|
1160
|
+
|
|
1161
|
+
describe("Compression", () => {
|
|
1162
|
+
test("compressUIMessageChunks merges consecutive text-delta parts", () => {
|
|
1163
|
+
const parts = [
|
|
1164
|
+
{ type: "text-delta" as const, id: "1", delta: "Hello" },
|
|
1165
|
+
{ type: "text-delta" as const, id: "1", delta: " " },
|
|
1166
|
+
{ type: "text-delta" as const, id: "1", delta: "World" },
|
|
1167
|
+
];
|
|
1168
|
+
const compressed = compressUIMessageChunks(parts);
|
|
1169
|
+
expect(compressed).toHaveLength(1);
|
|
1170
|
+
expect(compressed[0]).toEqual({
|
|
1171
|
+
type: "text-delta",
|
|
1172
|
+
id: "1",
|
|
1173
|
+
delta: "Hello World",
|
|
1174
|
+
});
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
test("compressUIMessageChunks does not merge different IDs", () => {
|
|
1178
|
+
const parts = [
|
|
1179
|
+
{ type: "text-delta" as const, id: "1", delta: "Hello" },
|
|
1180
|
+
{ type: "text-delta" as const, id: "2", delta: "World" },
|
|
1181
|
+
];
|
|
1182
|
+
const compressed = compressUIMessageChunks(parts);
|
|
1183
|
+
expect(compressed).toHaveLength(2);
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
test("compressUIMessageChunks merges consecutive reasoning-delta parts", () => {
|
|
1187
|
+
const parts = [
|
|
1188
|
+
{ type: "reasoning-delta" as const, id: "r1", delta: "Think" },
|
|
1189
|
+
{ type: "reasoning-delta" as const, id: "r1", delta: "ing" },
|
|
1190
|
+
];
|
|
1191
|
+
const compressed = compressUIMessageChunks(parts);
|
|
1192
|
+
expect(compressed).toHaveLength(1);
|
|
1193
|
+
expect((compressed[0] as { delta: string }).delta).toBe("Thinking");
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
test("compressUIMessageChunks preserves non-delta parts", () => {
|
|
1197
|
+
const parts = [
|
|
1198
|
+
{ type: "start" as const },
|
|
1199
|
+
{ type: "text-delta" as const, id: "1", delta: "A" },
|
|
1200
|
+
{ type: "text-delta" as const, id: "1", delta: "B" },
|
|
1201
|
+
{ type: "finish" as const },
|
|
1202
|
+
];
|
|
1203
|
+
const compressed = compressUIMessageChunks(parts as any);
|
|
1204
|
+
expect(compressed).toHaveLength(3); // start, merged text, finish
|
|
1205
|
+
});
|
|
1206
|
+
});
|