@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
|
@@ -164,6 +164,249 @@ describe("agent", () => {
|
|
|
164
164
|
});
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
test("an explicit order starts, appends, and avoids later order collisions", async () => {
|
|
168
|
+
const t = convexTest(schema, modules);
|
|
169
|
+
const thread = await t.mutation(api.threads.createThread, {
|
|
170
|
+
userId: "test",
|
|
171
|
+
});
|
|
172
|
+
await t.mutation(api.messages.addMessages, {
|
|
173
|
+
threadId: thread._id as Id<"threads">,
|
|
174
|
+
messages: [
|
|
175
|
+
{ message: { role: "user", content: "hello" } },
|
|
176
|
+
{ message: { role: "assistant", content: "agent reply" } },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const { messages: firstHumanReply } = await t.mutation(
|
|
181
|
+
api.messages.addMessages,
|
|
182
|
+
{
|
|
183
|
+
threadId: thread._id as Id<"threads">,
|
|
184
|
+
order: 1,
|
|
185
|
+
agentName: "human:Alex",
|
|
186
|
+
messages: [{ message: { role: "assistant", content: "human reply" } }],
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
expect(firstHumanReply[0]).toMatchObject({
|
|
190
|
+
order: 1,
|
|
191
|
+
stepOrder: 0,
|
|
192
|
+
agentName: "human:Alex",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const { messages: secondHumanReply } = await t.mutation(
|
|
196
|
+
api.messages.addMessages,
|
|
197
|
+
{
|
|
198
|
+
threadId: thread._id as Id<"threads">,
|
|
199
|
+
order: 1,
|
|
200
|
+
agentName: "human:Sam",
|
|
201
|
+
messages: [{ message: { role: "assistant", content: "follow-up" } }],
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
expect(secondHumanReply[0]).toMatchObject({
|
|
205
|
+
order: 1,
|
|
206
|
+
stepOrder: 1,
|
|
207
|
+
agentName: "human:Sam",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const { messages: backdatedBatch } = await t.mutation(
|
|
211
|
+
api.messages.addMessages,
|
|
212
|
+
{
|
|
213
|
+
threadId: thread._id as Id<"threads">,
|
|
214
|
+
order: 0,
|
|
215
|
+
messages: [
|
|
216
|
+
{ message: { role: "assistant", content: "backdated reply" } },
|
|
217
|
+
{ message: { role: "user", content: "new user turn" } },
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
expect(
|
|
222
|
+
backdatedBatch.map(({ order, stepOrder }) => [order, stepOrder]),
|
|
223
|
+
).toEqual([
|
|
224
|
+
[0, 2],
|
|
225
|
+
[2, 0],
|
|
226
|
+
]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("concurrent saves to an explicit order receive distinct step orders", async () => {
|
|
230
|
+
const t = convexTest(schema, modules);
|
|
231
|
+
const thread = await t.mutation(api.threads.createThread, {
|
|
232
|
+
userId: "test",
|
|
233
|
+
});
|
|
234
|
+
const saveReply = (agentName: string) =>
|
|
235
|
+
t.mutation(api.messages.addMessages, {
|
|
236
|
+
threadId: thread._id as Id<"threads">,
|
|
237
|
+
order: 3,
|
|
238
|
+
agentName,
|
|
239
|
+
messages: [
|
|
240
|
+
{ message: { role: "assistant" as const, content: agentName } },
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const replies = await Promise.all([saveReply("Alex"), saveReply("Sam")]);
|
|
245
|
+
|
|
246
|
+
expect(
|
|
247
|
+
replies
|
|
248
|
+
.map(({ messages }) => messages[0].stepOrder)
|
|
249
|
+
.sort((a, b) => a - b),
|
|
250
|
+
).toEqual([0, 1]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("next order is allocated after the latest message", async () => {
|
|
254
|
+
const t = convexTest(schema, modules);
|
|
255
|
+
const thread = await t.mutation(api.threads.createThread, {
|
|
256
|
+
userId: "test",
|
|
257
|
+
});
|
|
258
|
+
await t.mutation(api.messages.addMessages, {
|
|
259
|
+
threadId: thread._id as Id<"threads">,
|
|
260
|
+
messages: [{ message: { role: "user", content: "hello" } }],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const { messages } = await t.mutation(api.messages.addMessages, {
|
|
264
|
+
threadId: thread._id as Id<"threads">,
|
|
265
|
+
order: "next",
|
|
266
|
+
messages: [{ message: { role: "assistant", content: "separate reply" } }],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(messages[0]).toMatchObject({ order: 1, stepOrder: 0 });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("concurrent next orders receive distinct orders", async () => {
|
|
273
|
+
const t = convexTest(schema, modules);
|
|
274
|
+
const thread = await t.mutation(api.threads.createThread, {
|
|
275
|
+
userId: "test",
|
|
276
|
+
});
|
|
277
|
+
await t.mutation(api.messages.addMessages, {
|
|
278
|
+
threadId: thread._id as Id<"threads">,
|
|
279
|
+
messages: [{ message: { role: "user", content: "hello" } }],
|
|
280
|
+
});
|
|
281
|
+
const saveReply = (agentName: string) =>
|
|
282
|
+
t.mutation(api.messages.addMessages, {
|
|
283
|
+
threadId: thread._id as Id<"threads">,
|
|
284
|
+
order: "next",
|
|
285
|
+
agentName,
|
|
286
|
+
messages: [
|
|
287
|
+
{ message: { role: "assistant" as const, content: agentName } },
|
|
288
|
+
],
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const replies = await Promise.all([saveReply("Alex"), saveReply("Sam")]);
|
|
292
|
+
|
|
293
|
+
expect(
|
|
294
|
+
replies
|
|
295
|
+
.map(({ messages }) => messages[0])
|
|
296
|
+
.sort((a, b) => a.order - b.order)
|
|
297
|
+
.map(({ order, stepOrder }) => [order, stepOrder]),
|
|
298
|
+
).toEqual([
|
|
299
|
+
[1, 0],
|
|
300
|
+
[2, 0],
|
|
301
|
+
]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("an explicit order ahead of the thread places the batch there", async () => {
|
|
305
|
+
const t = convexTest(schema, modules);
|
|
306
|
+
const thread = await t.mutation(api.threads.createThread, {
|
|
307
|
+
userId: "test",
|
|
308
|
+
});
|
|
309
|
+
await t.mutation(api.messages.addMessages, {
|
|
310
|
+
threadId: thread._id as Id<"threads">,
|
|
311
|
+
messages: [{ message: { role: "user", content: "hello" } }],
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const { messages } = await t.mutation(api.messages.addMessages, {
|
|
315
|
+
threadId: thread._id as Id<"threads">,
|
|
316
|
+
order: 5,
|
|
317
|
+
messages: [
|
|
318
|
+
{ message: { role: "assistant", content: "imported reply" } },
|
|
319
|
+
{ message: { role: "user", content: "imported question" } },
|
|
320
|
+
],
|
|
321
|
+
});
|
|
322
|
+
expect(messages.map(({ order, stepOrder }) => [order, stepOrder])).toEqual([
|
|
323
|
+
[5, 0],
|
|
324
|
+
[6, 0],
|
|
325
|
+
]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("an explicit order cannot conflict with another placement argument", async () => {
|
|
329
|
+
const t = convexTest(schema, modules);
|
|
330
|
+
const thread = await t.mutation(api.threads.createThread, {
|
|
331
|
+
userId: "test",
|
|
332
|
+
});
|
|
333
|
+
const { messages } = await t.mutation(api.messages.addMessages, {
|
|
334
|
+
threadId: thread._id as Id<"threads">,
|
|
335
|
+
messages: [{ message: { role: "user", content: "hello" } }],
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await expect(
|
|
339
|
+
t.mutation(api.messages.addMessages, {
|
|
340
|
+
threadId: thread._id as Id<"threads">,
|
|
341
|
+
order: 1,
|
|
342
|
+
promptMessageId: messages[0]._id as Id<"messages">,
|
|
343
|
+
messages: [{ message: { role: "assistant", content: "reply" } }],
|
|
344
|
+
}),
|
|
345
|
+
).rejects.toThrow("order and promptMessageId cannot both be provided");
|
|
346
|
+
|
|
347
|
+
await expect(
|
|
348
|
+
t.mutation(api.messages.addMessages, {
|
|
349
|
+
threadId: thread._id as Id<"threads">,
|
|
350
|
+
order: "next",
|
|
351
|
+
promptMessageId: messages[0]._id as Id<"messages">,
|
|
352
|
+
messages: [{ message: { role: "assistant", content: "reply" } }],
|
|
353
|
+
}),
|
|
354
|
+
).rejects.toThrow("order and promptMessageId cannot both be provided");
|
|
355
|
+
|
|
356
|
+
for (const order of [-1, 1.5, Number.MAX_SAFE_INTEGER]) {
|
|
357
|
+
await expect(
|
|
358
|
+
t.mutation(api.messages.addMessages, {
|
|
359
|
+
threadId: thread._id as Id<"threads">,
|
|
360
|
+
order,
|
|
361
|
+
messages: [{ message: { role: "assistant", content: "reply" } }],
|
|
362
|
+
}),
|
|
363
|
+
).rejects.toThrow("order must be a non-negative safe integer");
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("derived message positions cannot exceed safe integers", async () => {
|
|
368
|
+
const t = convexTest(schema, modules);
|
|
369
|
+
const thread = await t.mutation(api.threads.createThread, {
|
|
370
|
+
userId: "test",
|
|
371
|
+
});
|
|
372
|
+
const { messages } = await t.mutation(api.messages.addMessages, {
|
|
373
|
+
threadId: thread._id as Id<"threads">,
|
|
374
|
+
order: 1,
|
|
375
|
+
messages: [{ message: { role: "assistant", content: "reply" } }],
|
|
376
|
+
});
|
|
377
|
+
const messageId = messages[0]._id as Id<"messages">;
|
|
378
|
+
|
|
379
|
+
await t.run(async (ctx) => {
|
|
380
|
+
await ctx.db.patch("messages", messageId, {
|
|
381
|
+
stepOrder: Number.MAX_SAFE_INTEGER,
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
await expect(
|
|
385
|
+
t.mutation(api.messages.addMessages, {
|
|
386
|
+
threadId: thread._id as Id<"threads">,
|
|
387
|
+
order: 1,
|
|
388
|
+
messages: [{ message: { role: "assistant", content: "follow-up" } }],
|
|
389
|
+
}),
|
|
390
|
+
).rejects.toThrow(
|
|
391
|
+
"stepOrder cannot be incremented past Number.MAX_SAFE_INTEGER",
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
await t.run(async (ctx) => {
|
|
395
|
+
await ctx.db.patch("messages", messageId, {
|
|
396
|
+
order: Number.MAX_SAFE_INTEGER,
|
|
397
|
+
stepOrder: 0,
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
await expect(
|
|
401
|
+
t.mutation(api.messages.addMessages, {
|
|
402
|
+
threadId: thread._id as Id<"threads">,
|
|
403
|
+
messages: [{ message: { role: "user", content: "new turn" } }],
|
|
404
|
+
}),
|
|
405
|
+
).rejects.toThrow(
|
|
406
|
+
"order cannot be incremented past Number.MAX_SAFE_INTEGER",
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
167
410
|
test("order is incremented for user messages on to addMessages for the same promptMessageId", async () => {
|
|
168
411
|
const t = convexTest(schema, modules);
|
|
169
412
|
const thread = await t.mutation(api.threads.createThread, {
|
|
@@ -1017,3 +1260,164 @@ describe("agent", () => {
|
|
|
1017
1260
|
});
|
|
1018
1261
|
});
|
|
1019
1262
|
});
|
|
1263
|
+
|
|
1264
|
+
describe("late saves racing a failed pending message (issue #320)", () => {
|
|
1265
|
+
const PROVIDER_ERROR = "invalid_prompt: Invalid prompt: flagged by policy.";
|
|
1266
|
+
|
|
1267
|
+
test("keeps the first durable failure authoritative", async () => {
|
|
1268
|
+
const t = initConvexTest();
|
|
1269
|
+
const thread = await t.mutation(api.threads.createThread, {
|
|
1270
|
+
userId: "u1",
|
|
1271
|
+
});
|
|
1272
|
+
const threadId = thread._id as Id<"threads">;
|
|
1273
|
+
|
|
1274
|
+
const { messages: seeded } = await t.mutation(api.messages.addMessages, {
|
|
1275
|
+
threadId,
|
|
1276
|
+
messages: [
|
|
1277
|
+
{ message: { role: "user", content: "hello" } },
|
|
1278
|
+
{ message: { role: "assistant", content: [] }, status: "pending" },
|
|
1279
|
+
],
|
|
1280
|
+
});
|
|
1281
|
+
const pending = seeded.at(-1)!;
|
|
1282
|
+
expect(pending.status).toBe("pending");
|
|
1283
|
+
|
|
1284
|
+
const streamId = await t.mutation(api.streams.create, {
|
|
1285
|
+
threadId,
|
|
1286
|
+
order: pending.order,
|
|
1287
|
+
stepOrder: pending.stepOrder,
|
|
1288
|
+
format: "UIMessageChunk",
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
await t.mutation(api.messages.finalizeMessage, {
|
|
1292
|
+
messageId: pending._id as Id<"messages">,
|
|
1293
|
+
result: { status: "failed", error: PROVIDER_ERROR },
|
|
1294
|
+
});
|
|
1295
|
+
await t.mutation(api.streams.abort, { streamId, reason: PROVIDER_ERROR });
|
|
1296
|
+
|
|
1297
|
+
const { messages: late } = await t.mutation(api.messages.addMessages, {
|
|
1298
|
+
threadId,
|
|
1299
|
+
pendingMessageId: pending._id as Id<"messages">,
|
|
1300
|
+
finishStreamId: streamId,
|
|
1301
|
+
failPendingSteps: false,
|
|
1302
|
+
messages: [
|
|
1303
|
+
{ message: { role: "assistant", content: "partial response" } },
|
|
1304
|
+
],
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
const assistants = (
|
|
1308
|
+
await t.run(async (ctx) =>
|
|
1309
|
+
ctx.db
|
|
1310
|
+
.query("messages")
|
|
1311
|
+
.withIndex("threadId_status_tool_order_stepOrder", (q) =>
|
|
1312
|
+
q.eq("threadId", threadId),
|
|
1313
|
+
)
|
|
1314
|
+
.collect(),
|
|
1315
|
+
)
|
|
1316
|
+
).filter((message) => message.message?.role === "assistant");
|
|
1317
|
+
|
|
1318
|
+
expect(late).toHaveLength(1);
|
|
1319
|
+
expect(assistants).toHaveLength(1);
|
|
1320
|
+
expect(assistants[0]!._id).toBe(pending._id);
|
|
1321
|
+
expect(assistants[0]!.status).toBe("failed");
|
|
1322
|
+
expect(assistants[0]!.error).toBe(PROVIDER_ERROR);
|
|
1323
|
+
expect(assistants[0]!.text).toBe("partial response");
|
|
1324
|
+
|
|
1325
|
+
const stream = await t.run((ctx) =>
|
|
1326
|
+
ctx.db.get("streamingMessages", streamId),
|
|
1327
|
+
);
|
|
1328
|
+
expect(stream?.state.kind).toBe("aborted");
|
|
1329
|
+
});
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
describe("deleting a message aborts generation writing to it (issue #300)", () => {
|
|
1333
|
+
test("a stream at the deleted order is aborted", async () => {
|
|
1334
|
+
const t = initConvexTest();
|
|
1335
|
+
const thread = await t.mutation(api.threads.createThread, { userId: "u" });
|
|
1336
|
+
const threadId = thread._id as Id<"threads">;
|
|
1337
|
+
|
|
1338
|
+
const { messages } = await t.mutation(api.messages.addMessages, {
|
|
1339
|
+
threadId,
|
|
1340
|
+
messages: [{ message: { role: "user", content: "hello" } }],
|
|
1341
|
+
});
|
|
1342
|
+
const prompt = messages[0];
|
|
1343
|
+
|
|
1344
|
+
await t.mutation(api.streams.create, {
|
|
1345
|
+
threadId,
|
|
1346
|
+
order: prompt.order,
|
|
1347
|
+
stepOrder: prompt.stepOrder + 1,
|
|
1348
|
+
userId: "u",
|
|
1349
|
+
agentName: "a",
|
|
1350
|
+
model: "m",
|
|
1351
|
+
provider: "p",
|
|
1352
|
+
format: "UIMessageChunk",
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
await t.mutation(api.messages.deleteByIds, {
|
|
1356
|
+
messageIds: [prompt._id as Id<"messages">],
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
const streaming = await t.query(api.streams.list, {
|
|
1360
|
+
threadId,
|
|
1361
|
+
statuses: ["streaming"],
|
|
1362
|
+
});
|
|
1363
|
+
const aborted = await t.query(api.streams.list, {
|
|
1364
|
+
threadId,
|
|
1365
|
+
statuses: ["aborted"],
|
|
1366
|
+
});
|
|
1367
|
+
expect(streaming).toHaveLength(0);
|
|
1368
|
+
expect(aborted).toHaveLength(1);
|
|
1369
|
+
});
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
describe("abandoning a save whose prompt was deleted (issue #300)", () => {
|
|
1373
|
+
test("abandons instead of throwing when the caller opts in", async () => {
|
|
1374
|
+
const t = initConvexTest();
|
|
1375
|
+
const thread = await t.mutation(api.threads.createThread, { userId: "u" });
|
|
1376
|
+
const threadId = thread._id as Id<"threads">;
|
|
1377
|
+
|
|
1378
|
+
const { messages } = await t.mutation(api.messages.addMessages, {
|
|
1379
|
+
threadId,
|
|
1380
|
+
messages: [{ message: { role: "user", content: "hello" } }],
|
|
1381
|
+
});
|
|
1382
|
+
const promptMessageId = messages[0]._id as Id<"messages">;
|
|
1383
|
+
|
|
1384
|
+
await t.mutation(api.messages.deleteByIds, { messageIds: [promptMessageId] });
|
|
1385
|
+
|
|
1386
|
+
const saved = await t.mutation(api.messages.addMessages, {
|
|
1387
|
+
threadId,
|
|
1388
|
+
promptMessageId,
|
|
1389
|
+
abandonIfPromptMissing: true,
|
|
1390
|
+
messages: [{ message: { role: "assistant", content: "answer" } }],
|
|
1391
|
+
});
|
|
1392
|
+
expect(saved.messages).toEqual([]);
|
|
1393
|
+
|
|
1394
|
+
// Nothing was grafted onto the thread.
|
|
1395
|
+
const all = await t.query(api.messages.listMessagesByThreadId, {
|
|
1396
|
+
threadId,
|
|
1397
|
+
order: "asc",
|
|
1398
|
+
paginationOpts: { cursor: null, numItems: 10 },
|
|
1399
|
+
});
|
|
1400
|
+
expect(all.page).toHaveLength(0);
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
test("still throws for a caller that did not opt in", async () => {
|
|
1404
|
+
const t = initConvexTest();
|
|
1405
|
+
const thread = await t.mutation(api.threads.createThread, { userId: "u" });
|
|
1406
|
+
const threadId = thread._id as Id<"threads">;
|
|
1407
|
+
|
|
1408
|
+
const { messages } = await t.mutation(api.messages.addMessages, {
|
|
1409
|
+
threadId,
|
|
1410
|
+
messages: [{ message: { role: "user", content: "hello" } }],
|
|
1411
|
+
});
|
|
1412
|
+
const promptMessageId = messages[0]._id as Id<"messages">;
|
|
1413
|
+
await t.mutation(api.messages.deleteByIds, { messageIds: [promptMessageId] });
|
|
1414
|
+
|
|
1415
|
+
await expect(
|
|
1416
|
+
t.mutation(api.messages.addMessages, {
|
|
1417
|
+
threadId,
|
|
1418
|
+
promptMessageId,
|
|
1419
|
+
messages: [{ message: { role: "assistant", content: "answer" } }],
|
|
1420
|
+
}),
|
|
1421
|
+
).rejects.toThrow("not found");
|
|
1422
|
+
});
|
|
1423
|
+
});
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
getStreamingMessagesWithMetadata,
|
|
45
45
|
finishHandler,
|
|
46
46
|
releaseStreamFileOwnershipByIds,
|
|
47
|
+
abortStreamsAtOrder,
|
|
47
48
|
} from "./streams.js";
|
|
48
49
|
import { partial } from "convex-helpers/validators";
|
|
49
50
|
|
|
@@ -65,21 +66,45 @@ export async function deleteMessage(
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Deleting a message strands any generation still writing to its order, which
|
|
71
|
+
* would otherwise only surface as a missing-parent failure when that generation
|
|
72
|
+
* finalizes. Aborting the stream lets the in-flight run stop on its own.
|
|
73
|
+
*/
|
|
74
|
+
async function abortStreamsForDeleted(
|
|
75
|
+
ctx: MutationCtx,
|
|
76
|
+
deleted: (Doc<"messages"> | null)[],
|
|
77
|
+
) {
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
for (const message of deleted) {
|
|
80
|
+
if (!message) continue;
|
|
81
|
+
const key = `${message.threadId}:${message.order}`;
|
|
82
|
+
if (seen.has(key)) continue;
|
|
83
|
+
seen.add(key);
|
|
84
|
+
await abortStreamsAtOrder(ctx, {
|
|
85
|
+
threadId: message.threadId,
|
|
86
|
+
order: message.order,
|
|
87
|
+
reason: "Message deleted",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
export const deleteByIds = mutation({
|
|
69
93
|
args: { messageIds: v.array(v.id("messages")) },
|
|
70
94
|
returns: v.array(v.id("messages")),
|
|
71
95
|
handler: async (ctx, args) => {
|
|
72
|
-
const
|
|
96
|
+
const deleted = await Promise.all(
|
|
73
97
|
args.messageIds.map(async (id) => {
|
|
74
98
|
const message = await ctx.db.get("messages", id);
|
|
75
99
|
if (message) {
|
|
76
100
|
await deleteMessage(ctx, message);
|
|
77
|
-
return
|
|
101
|
+
return message;
|
|
78
102
|
}
|
|
79
103
|
return null;
|
|
80
104
|
}),
|
|
81
105
|
);
|
|
82
|
-
|
|
106
|
+
await abortStreamsForDeleted(ctx, deleted);
|
|
107
|
+
return deleted.filter((m) => m !== null).map((m) => m._id);
|
|
83
108
|
},
|
|
84
109
|
});
|
|
85
110
|
|
|
@@ -145,6 +170,7 @@ export const deleteByOrder = mutation({
|
|
|
145
170
|
})
|
|
146
171
|
.take(64);
|
|
147
172
|
await Promise.all(messages.map((m) => deleteMessage(ctx, m)));
|
|
173
|
+
await abortStreamsForDeleted(ctx, messages);
|
|
148
174
|
return {
|
|
149
175
|
isDone: messages.length < 64,
|
|
150
176
|
lastOrder: messages.at(-1)?.order,
|
|
@@ -157,6 +183,13 @@ const addMessagesArgs = {
|
|
|
157
183
|
userId: v.optional(v.string()),
|
|
158
184
|
threadId: v.id("threads"),
|
|
159
185
|
promptMessageId: v.optional(v.id("messages")),
|
|
186
|
+
/**
|
|
187
|
+
* For saves that belong to a run anchored on promptMessageId: if that
|
|
188
|
+
* message is gone the run is obsolete, so abandon the save instead of
|
|
189
|
+
* throwing. A caller passing an id that never existed still gets an error.
|
|
190
|
+
*/
|
|
191
|
+
abandonIfPromptMissing: v.optional(v.boolean()),
|
|
192
|
+
order: v.optional(v.union(v.number(), v.literal("next"))),
|
|
160
193
|
agentName: v.optional(v.string()),
|
|
161
194
|
messages: v.array(vMessageWithMetadataInternal),
|
|
162
195
|
embeddings: v.optional(vMessageEmbeddingsWithDimension),
|
|
@@ -175,6 +208,15 @@ export const addMessages = mutation({
|
|
|
175
208
|
handler: addMessagesHandler,
|
|
176
209
|
returns: v.object({ messages: v.array(vMessageDoc) }),
|
|
177
210
|
});
|
|
211
|
+
|
|
212
|
+
function incrementMessagePosition(value: number, field: "order" | "stepOrder") {
|
|
213
|
+
assert(
|
|
214
|
+
Number.isSafeInteger(value) && value < Number.MAX_SAFE_INTEGER,
|
|
215
|
+
`${field} cannot be incremented past Number.MAX_SAFE_INTEGER`,
|
|
216
|
+
);
|
|
217
|
+
return value + 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
178
220
|
async function addMessagesHandler(
|
|
179
221
|
ctx: MutationCtx,
|
|
180
222
|
args: ObjectType<typeof addMessagesArgs>,
|
|
@@ -193,12 +235,30 @@ async function addMessagesHandler(
|
|
|
193
235
|
finishStreamId,
|
|
194
236
|
messages,
|
|
195
237
|
promptMessageId,
|
|
238
|
+
abandonIfPromptMissing,
|
|
239
|
+
order: requestedOrder,
|
|
196
240
|
pendingMessageId,
|
|
197
241
|
hideFromUserIdSearch,
|
|
198
242
|
...rest
|
|
199
243
|
} = args;
|
|
200
244
|
const promptMessage =
|
|
201
245
|
promptMessageId && (await ctx.db.get("messages", promptMessageId));
|
|
246
|
+
assert(
|
|
247
|
+
requestedOrder === undefined ||
|
|
248
|
+
requestedOrder === "next" ||
|
|
249
|
+
(Number.isSafeInteger(requestedOrder) &&
|
|
250
|
+
requestedOrder >= 0 &&
|
|
251
|
+
requestedOrder < Number.MAX_SAFE_INTEGER),
|
|
252
|
+
"order must be a non-negative safe integer less than Number.MAX_SAFE_INTEGER",
|
|
253
|
+
);
|
|
254
|
+
assert(
|
|
255
|
+
requestedOrder === undefined || !promptMessageId,
|
|
256
|
+
"order and promptMessageId cannot both be provided",
|
|
257
|
+
);
|
|
258
|
+
assert(
|
|
259
|
+
requestedOrder === undefined || !pendingMessageId,
|
|
260
|
+
"order and pendingMessageId cannot both be provided",
|
|
261
|
+
);
|
|
202
262
|
if (failPendingSteps) {
|
|
203
263
|
assert(args.threadId, "threadId is required to fail pending steps");
|
|
204
264
|
const pendingMessages = await ctx.db
|
|
@@ -228,7 +288,21 @@ async function addMessagesHandler(
|
|
|
228
288
|
let order, stepOrder;
|
|
229
289
|
let fail = false;
|
|
230
290
|
let error: string | undefined;
|
|
231
|
-
|
|
291
|
+
const startsAtNextOrder = requestedOrder === "next";
|
|
292
|
+
const explicitOrder =
|
|
293
|
+
typeof requestedOrder === "number" ? requestedOrder : undefined;
|
|
294
|
+
if (startsAtNextOrder) {
|
|
295
|
+
const maxMessage = await getMaxMessage(ctx, threadId);
|
|
296
|
+
order = incrementMessagePosition(maxMessage?.order ?? -1, "order");
|
|
297
|
+
stepOrder = -1;
|
|
298
|
+
} else if (explicitOrder !== undefined) {
|
|
299
|
+
order = explicitOrder;
|
|
300
|
+
const maxMessage = await getMaxMessage(ctx, threadId, order);
|
|
301
|
+
stepOrder = maxMessage?.stepOrder ?? -1;
|
|
302
|
+
} else if (promptMessageId) {
|
|
303
|
+
if (!promptMessage && abandonIfPromptMissing) {
|
|
304
|
+
return { messages: [] };
|
|
305
|
+
}
|
|
232
306
|
assert(promptMessage, `Parent message ${promptMessageId} not found`);
|
|
233
307
|
if (promptMessage.status === "failed") {
|
|
234
308
|
fail = true;
|
|
@@ -289,8 +363,8 @@ async function addMessagesHandler(
|
|
|
289
363
|
if (pendingMessage.status === "failed") {
|
|
290
364
|
fail = true;
|
|
291
365
|
error =
|
|
292
|
-
|
|
293
|
-
`
|
|
366
|
+
pendingMessage.error ??
|
|
367
|
+
`Trying to update a message that failed: ${pendingMessageId}`;
|
|
294
368
|
messageDoc.status = "failed";
|
|
295
369
|
messageDoc.error = error;
|
|
296
370
|
}
|
|
@@ -309,20 +383,28 @@ async function addMessagesHandler(
|
|
|
309
383
|
toReturn.push((await ctx.db.get("messages", pendingMessage._id))!);
|
|
310
384
|
continue;
|
|
311
385
|
}
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
386
|
+
if ((startsAtNextOrder || explicitOrder !== undefined) && i === 0) {
|
|
387
|
+
stepOrder = incrementMessagePosition(stepOrder, "stepOrder");
|
|
388
|
+
} else if (message.message.role === "user") {
|
|
389
|
+
if (
|
|
390
|
+
(explicitOrder !== undefined && order === explicitOrder) ||
|
|
391
|
+
(promptMessage && promptMessage.order === order)
|
|
392
|
+
) {
|
|
393
|
+
// Avoid colliding with a later order when saving from an older one.
|
|
315
394
|
const maxMessage = await getMaxMessage(ctx, threadId);
|
|
316
|
-
order = (
|
|
395
|
+
order = incrementMessagePosition(
|
|
396
|
+
Math.max(maxMessage?.order ?? order, order),
|
|
397
|
+
"order",
|
|
398
|
+
);
|
|
317
399
|
} else {
|
|
318
|
-
order
|
|
400
|
+
order = incrementMessagePosition(order, "order");
|
|
319
401
|
}
|
|
320
402
|
stepOrder = 0;
|
|
321
403
|
} else {
|
|
322
404
|
if (order < 0) {
|
|
323
405
|
order = 0;
|
|
324
406
|
}
|
|
325
|
-
stepOrder
|
|
407
|
+
stepOrder = incrementMessagePosition(stepOrder, "stepOrder");
|
|
326
408
|
}
|
|
327
409
|
const messageId = await ctx.db.insert("messages", {
|
|
328
410
|
...messageDoc,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/// <reference types="vite/client" />
|
|
2
2
|
|
|
3
|
-
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
4
4
|
import { api } from "./_generated/api.js";
|
|
5
5
|
import type { Id } from "./_generated/dataModel.js";
|
|
6
6
|
import { initConvexTest } from "./setup.test.js";
|
|
7
7
|
|
|
8
|
+
afterEach(() => vi.useRealTimers());
|
|
9
|
+
|
|
8
10
|
async function seedStream(t: ReturnType<typeof initConvexTest>) {
|
|
9
11
|
const thread = await t.mutation(api.threads.createThread, {
|
|
10
12
|
userId: "stream-files",
|
|
@@ -106,4 +108,55 @@ describe("streams", () => {
|
|
|
106
108
|
expect(stream?.state.kind).toBe("finished");
|
|
107
109
|
expect(stream?.fileRefs).toBeUndefined();
|
|
108
110
|
});
|
|
111
|
+
|
|
112
|
+
test("bulk deletion preserves global stream order across status lanes", async () => {
|
|
113
|
+
vi.useFakeTimers();
|
|
114
|
+
|
|
115
|
+
const t = initConvexTest();
|
|
116
|
+
const thread = await t.mutation(api.threads.createThread, {
|
|
117
|
+
userId: "stream-cleanup",
|
|
118
|
+
});
|
|
119
|
+
const threadId = thread._id as Id<"threads">;
|
|
120
|
+
const earlyStreamId = await t.run(async (ctx) => {
|
|
121
|
+
const streamId = await ctx.db.insert("streamingMessages", {
|
|
122
|
+
threadId,
|
|
123
|
+
order: 1,
|
|
124
|
+
stepOrder: 0,
|
|
125
|
+
format: "UIMessageChunk",
|
|
126
|
+
state: { kind: "streaming", lastHeartbeat: Date.now() },
|
|
127
|
+
});
|
|
128
|
+
await ctx.db.insert("streamingMessages", {
|
|
129
|
+
threadId,
|
|
130
|
+
order: 9,
|
|
131
|
+
stepOrder: 0,
|
|
132
|
+
format: "UIMessageChunk",
|
|
133
|
+
state: { kind: "aborted", reason: "retained regeneration" },
|
|
134
|
+
});
|
|
135
|
+
await ctx.db.insert("streamDeltas", {
|
|
136
|
+
streamId,
|
|
137
|
+
start: 0,
|
|
138
|
+
end: 1,
|
|
139
|
+
parts: [{ type: "text-start", id: "text" }],
|
|
140
|
+
});
|
|
141
|
+
return streamId;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await t.mutation(api.streams.deleteAllStreamsForThreadIdAsync, {
|
|
145
|
+
threadId,
|
|
146
|
+
});
|
|
147
|
+
await t.finishAllScheduledFunctions(vi.runAllTimers);
|
|
148
|
+
|
|
149
|
+
expect(
|
|
150
|
+
await t.query(api.streams.list, {
|
|
151
|
+
threadId,
|
|
152
|
+
statuses: ["streaming", "finished", "aborted"],
|
|
153
|
+
}),
|
|
154
|
+
).toEqual([]);
|
|
155
|
+
expect(
|
|
156
|
+
await t.query(api.streams.listDeltas, {
|
|
157
|
+
threadId,
|
|
158
|
+
cursors: [{ streamId: earlyStreamId, cursor: 0 }],
|
|
159
|
+
}),
|
|
160
|
+
).toEqual([]);
|
|
161
|
+
});
|
|
109
162
|
});
|