@convex-dev/agent 0.6.3 → 0.6.4
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.map +1 -1
- package/dist/client/definePlaygroundAPI.d.ts +69 -64
- package/dist/client/definePlaygroundAPI.d.ts.map +1 -1
- package/dist/client/definePlaygroundAPI.js +8 -5
- package/dist/client/definePlaygroundAPI.js.map +1 -1
- package/dist/client/index.d.ts +12 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +11 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/saveInputMessages.d.ts.map +1 -1
- package/dist/client/saveInputMessages.js.map +1 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/component/apiKeys.js +5 -5
- package/dist/component/apiKeys.js.map +1 -1
- package/dist/component/files.d.ts.map +1 -1
- package/dist/component/files.js +13 -11
- package/dist/component/files.js.map +1 -1
- package/dist/component/messages.d.ts.map +1 -1
- package/dist/component/messages.js +37 -27
- package/dist/component/messages.js.map +1 -1
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +22 -17
- package/dist/component/streams.js.map +1 -1
- package/dist/component/threads.js +7 -7
- package/dist/component/threads.js.map +1 -1
- package/dist/component/users.js +2 -2
- package/dist/component/users.js.map +1 -1
- package/dist/component/vector/index.d.ts.map +1 -1
- package/dist/component/vector/index.js +14 -8
- package/dist/component/vector/index.js.map +1 -1
- package/dist/deltas.d.ts +16 -27
- package/dist/deltas.d.ts.map +1 -1
- package/dist/deltas.js +269 -286
- package/dist/deltas.js.map +1 -1
- package/dist/mapping.d.ts +9 -3
- package/dist/mapping.d.ts.map +1 -1
- package/dist/mapping.js +16 -14
- package/dist/mapping.js.map +1 -1
- package/dist/react/useStreamingUIMessages.d.ts.map +1 -1
- package/dist/react/useStreamingUIMessages.js +42 -26
- package/dist/react/useStreamingUIMessages.js.map +1 -1
- package/dist/react/useUIMessages.d.ts +1 -0
- package/dist/react/useUIMessages.d.ts.map +1 -1
- package/dist/react/useUIMessages.js +7 -3
- package/dist/react/useUIMessages.js.map +1 -1
- package/package.json +2 -1
- package/src/UIMessages.ts +1 -2
- package/src/client/approval.test.ts +25 -6
- package/src/client/createTool.ts +1 -1
- package/src/client/definePlaygroundAPI.ts +33 -17
- package/src/client/index.test.ts +91 -0
- package/src/client/index.ts +25 -1
- package/src/client/saveInputMessages.ts +4 -1
- package/src/client/streaming.integration.test.ts +39 -117
- package/src/client/types.ts +4 -17
- package/src/component/apiKeys.ts +5 -5
- package/src/component/files.test.ts +1 -1
- package/src/component/files.ts +14 -12
- package/src/component/messages.ts +40 -28
- package/src/component/streams.ts +33 -17
- package/src/component/threads.ts +7 -7
- package/src/component/users.ts +2 -2
- package/src/component/vector/index.ts +14 -7
- package/src/deltas.test.ts +373 -392
- package/src/deltas.ts +339 -378
- package/src/mapping.test.ts +296 -18
- package/src/mapping.ts +17 -11
- package/src/react/useStreamingUIMessages.ts +62 -34
- package/src/react/useUIMessages.test.ts +80 -1
- package/src/react/useUIMessages.ts +11 -3
package/src/mapping.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
serializeDataOrUrl,
|
|
5
5
|
toModelMessageDataOrUrl,
|
|
6
6
|
serializeMessage,
|
|
7
|
+
serializeNewMessagesInStep,
|
|
7
8
|
toModelMessage,
|
|
8
9
|
serializeContent,
|
|
9
10
|
toModelMessageContent,
|
|
@@ -16,7 +17,7 @@ import fs from "fs";
|
|
|
16
17
|
import path from "path";
|
|
17
18
|
import type { SerializedContent } from "./mapping.js";
|
|
18
19
|
import { validate } from "convex-helpers/validators";
|
|
19
|
-
import type { ToolResultPart } from "ai";
|
|
20
|
+
import type { ModelMessage, StepResult, ToolResultPart, ToolSet } from "ai";
|
|
20
21
|
import type { Infer } from "convex/values";
|
|
21
22
|
|
|
22
23
|
const testAssetsDir = path.join(__dirname, "../test-assets");
|
|
@@ -259,6 +260,245 @@ describe("mapping", () => {
|
|
|
259
260
|
expect((content as unknown[])[0]).toMatchObject(approvalResponse);
|
|
260
261
|
});
|
|
261
262
|
|
|
263
|
+
describe("serializeNewMessagesInStep", () => {
|
|
264
|
+
const ctx = {
|
|
265
|
+
runAction: async () => undefined,
|
|
266
|
+
runMutation: async () => undefined,
|
|
267
|
+
storage: {
|
|
268
|
+
store: async () => "storageId",
|
|
269
|
+
getUrl: async () => "https://example.com/file",
|
|
270
|
+
delete: async () => undefined,
|
|
271
|
+
},
|
|
272
|
+
} as unknown as ActionCtx;
|
|
273
|
+
const component = api as unknown as AgentComponent;
|
|
274
|
+
|
|
275
|
+
const step0Messages: ModelMessage[] = [
|
|
276
|
+
{
|
|
277
|
+
role: "assistant",
|
|
278
|
+
content: [
|
|
279
|
+
{ type: "tool-call", toolCallId: "c1", toolName: "search", input: {} },
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
role: "tool",
|
|
284
|
+
content: [
|
|
285
|
+
{
|
|
286
|
+
type: "tool-result",
|
|
287
|
+
toolCallId: "c1",
|
|
288
|
+
toolName: "search",
|
|
289
|
+
output: { type: "text", value: "ok" },
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
const step1Messages: ModelMessage[] = [
|
|
295
|
+
...step0Messages,
|
|
296
|
+
{ role: "assistant", content: [{ type: "text", text: "thinking" }] },
|
|
297
|
+
];
|
|
298
|
+
const step2Messages: ModelMessage[] = [
|
|
299
|
+
...step1Messages,
|
|
300
|
+
{
|
|
301
|
+
role: "assistant",
|
|
302
|
+
content: [
|
|
303
|
+
{ type: "tool-call", toolCallId: "c2", toolName: "search", input: {} },
|
|
304
|
+
],
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
role: "tool",
|
|
308
|
+
content: [
|
|
309
|
+
{
|
|
310
|
+
type: "tool-result",
|
|
311
|
+
toolCallId: "c2",
|
|
312
|
+
toolName: "search",
|
|
313
|
+
output: { type: "text", value: "done" },
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
const makeStep = (messages: ModelMessage[]): StepResult<ToolSet> =>
|
|
320
|
+
({
|
|
321
|
+
content: [],
|
|
322
|
+
text: "",
|
|
323
|
+
reasoning: [],
|
|
324
|
+
reasoningText: undefined,
|
|
325
|
+
files: [],
|
|
326
|
+
sources: [],
|
|
327
|
+
toolCalls: [],
|
|
328
|
+
staticToolCalls: [],
|
|
329
|
+
dynamicToolCalls: [],
|
|
330
|
+
toolResults: [],
|
|
331
|
+
staticToolResults: [],
|
|
332
|
+
dynamicToolResults: [],
|
|
333
|
+
finishReason: "stop",
|
|
334
|
+
rawFinishReason: undefined,
|
|
335
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
336
|
+
warnings: undefined,
|
|
337
|
+
request: {},
|
|
338
|
+
response: {
|
|
339
|
+
id: "resp",
|
|
340
|
+
timestamp: new Date(),
|
|
341
|
+
modelId: "test",
|
|
342
|
+
messages,
|
|
343
|
+
},
|
|
344
|
+
providerMetadata: undefined,
|
|
345
|
+
}) as unknown as StepResult<ToolSet>;
|
|
346
|
+
|
|
347
|
+
const contentTypes = (msg: { content: unknown }): string[] => {
|
|
348
|
+
const c = msg.content;
|
|
349
|
+
if (!Array.isArray(c)) return ["text"];
|
|
350
|
+
return c.map((p: { type?: string }) => p.type ?? "?");
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
test("first step (count=0) serializes all response messages", async () => {
|
|
354
|
+
const res = await serializeNewMessagesInStep(
|
|
355
|
+
ctx,
|
|
356
|
+
component,
|
|
357
|
+
makeStep(step0Messages),
|
|
358
|
+
undefined,
|
|
359
|
+
0,
|
|
360
|
+
);
|
|
361
|
+
expect(res.messages).toHaveLength(2);
|
|
362
|
+
expect(res.messages[0].message.role).toBe("assistant");
|
|
363
|
+
expect(contentTypes(res.messages[0].message)).toEqual(["tool-call"]);
|
|
364
|
+
expect(res.messages[1].message.role).toBe("tool");
|
|
365
|
+
expect(contentTypes(res.messages[1].message)).toEqual(["tool-result"]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("middle step (count=2) serializes only the new text message", async () => {
|
|
369
|
+
const res = await serializeNewMessagesInStep(
|
|
370
|
+
ctx,
|
|
371
|
+
component,
|
|
372
|
+
makeStep(step1Messages),
|
|
373
|
+
undefined,
|
|
374
|
+
2,
|
|
375
|
+
);
|
|
376
|
+
expect(res.messages).toHaveLength(1);
|
|
377
|
+
expect(res.messages[0].message.role).toBe("assistant");
|
|
378
|
+
expect(contentTypes(res.messages[0].message)).toEqual(["text"]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("multi-message step (count=3) serializes the new tool-call + tool-result pair", async () => {
|
|
382
|
+
const res = await serializeNewMessagesInStep(
|
|
383
|
+
ctx,
|
|
384
|
+
component,
|
|
385
|
+
makeStep(step2Messages),
|
|
386
|
+
undefined,
|
|
387
|
+
3,
|
|
388
|
+
);
|
|
389
|
+
expect(res.messages).toHaveLength(2);
|
|
390
|
+
expect(res.messages[0].message.role).toBe("assistant");
|
|
391
|
+
expect(contentTypes(res.messages[0].message)).toEqual(["tool-call"]);
|
|
392
|
+
expect(res.messages[1].message.role).toBe("tool");
|
|
393
|
+
expect(contentTypes(res.messages[1].message)).toEqual(["tool-result"]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// Regression test for the actually-broken shape: a single step appended
|
|
397
|
+
// assistant(text) + assistant(tool-call) + tool(tool-result), so the new
|
|
398
|
+
// tail has length 3 and the last message is a tool message. The old
|
|
399
|
+
// heuristic took `slice(-2)` whenever the last role was "tool" and would
|
|
400
|
+
// have dropped the leading text. The watermark returns all three.
|
|
401
|
+
test("returns all three messages when a step adds text + tool-call + tool-result", async () => {
|
|
402
|
+
const stepMessages: ModelMessage[] = [
|
|
403
|
+
...step0Messages, // length 2
|
|
404
|
+
{ role: "assistant", content: [{ type: "text", text: "Let me check..." }] },
|
|
405
|
+
{
|
|
406
|
+
role: "assistant",
|
|
407
|
+
content: [
|
|
408
|
+
{ type: "tool-call", toolCallId: "c3", toolName: "search", input: {} },
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
role: "tool",
|
|
413
|
+
content: [
|
|
414
|
+
{
|
|
415
|
+
type: "tool-result",
|
|
416
|
+
toolCallId: "c3",
|
|
417
|
+
toolName: "search",
|
|
418
|
+
output: { type: "text", value: "done" },
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
},
|
|
422
|
+
];
|
|
423
|
+
const res = await serializeNewMessagesInStep(
|
|
424
|
+
ctx,
|
|
425
|
+
component,
|
|
426
|
+
makeStep(stepMessages),
|
|
427
|
+
undefined,
|
|
428
|
+
step0Messages.length,
|
|
429
|
+
);
|
|
430
|
+
expect(res.messages).toHaveLength(3);
|
|
431
|
+
expect(res.messages[0].message.role).toBe("assistant");
|
|
432
|
+
expect(contentTypes(res.messages[0].message)).toEqual(["text"]);
|
|
433
|
+
expect(res.messages[1].message.role).toBe("assistant");
|
|
434
|
+
expect(contentTypes(res.messages[1].message)).toEqual(["tool-call"]);
|
|
435
|
+
expect(res.messages[2].message.role).toBe("tool");
|
|
436
|
+
expect(contentTypes(res.messages[2].message)).toEqual(["tool-result"]);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("empty response messages slice falls back to synthetic empty assistant", async () => {
|
|
440
|
+
const res = await serializeNewMessagesInStep(
|
|
441
|
+
ctx,
|
|
442
|
+
component,
|
|
443
|
+
makeStep(step1Messages),
|
|
444
|
+
undefined,
|
|
445
|
+
step1Messages.length,
|
|
446
|
+
);
|
|
447
|
+
expect(res.messages).toHaveLength(1);
|
|
448
|
+
expect(res.messages[0].message.role).toBe("assistant");
|
|
449
|
+
expect(res.messages[0].message.content).toEqual([]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Pin the caller-drift behavior: if the watermark is past the end of
|
|
453
|
+
// response.messages (e.g. the caller mistracked), the slice is empty and
|
|
454
|
+
// we fall through to the synthetic anchor. Future "fixes" should not
|
|
455
|
+
// accidentally change this without intent.
|
|
456
|
+
test("watermark beyond response.messages.length returns the synthetic fallback", async () => {
|
|
457
|
+
const res = await serializeNewMessagesInStep(
|
|
458
|
+
ctx,
|
|
459
|
+
component,
|
|
460
|
+
makeStep(step1Messages),
|
|
461
|
+
undefined,
|
|
462
|
+
step1Messages.length + 5,
|
|
463
|
+
);
|
|
464
|
+
expect(res.messages).toHaveLength(1);
|
|
465
|
+
expect(res.messages[0].message.role).toBe("assistant");
|
|
466
|
+
expect(res.messages[0].message.content).toEqual([]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// AI SDK v6 makes step.response.messages cumulative across steps:
|
|
470
|
+
// step N's array contains all messages from steps 0..N. Without the
|
|
471
|
+
// previousResponseMessageCount watermark, every multi-step save duplicates
|
|
472
|
+
// all prior messages. These tests demonstrate the bug and the fix.
|
|
473
|
+
describe("multi-step loop — previousStep watermark", () => {
|
|
474
|
+
test("without watermark, step 2 re-saves all cumulative messages (demonstrates the bug)", async () => {
|
|
475
|
+
// step2Messages = step0 (2 msgs) + step1 (1 msg) + step2 new (2 msgs) = 5 total
|
|
476
|
+
const res = await serializeNewMessagesInStep(
|
|
477
|
+
ctx,
|
|
478
|
+
component,
|
|
479
|
+
makeStep(step2Messages),
|
|
480
|
+
undefined,
|
|
481
|
+
0,
|
|
482
|
+
);
|
|
483
|
+
expect(res.messages).toHaveLength(5);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("with watermark, step 2 saves only its 2 new messages", async () => {
|
|
487
|
+
const step1 = makeStep(step1Messages);
|
|
488
|
+
const res = await serializeNewMessagesInStep(
|
|
489
|
+
ctx,
|
|
490
|
+
component,
|
|
491
|
+
makeStep(step2Messages),
|
|
492
|
+
undefined,
|
|
493
|
+
step1.response.messages.length,
|
|
494
|
+
);
|
|
495
|
+
expect(res.messages).toHaveLength(2);
|
|
496
|
+
expect(contentTypes(res.messages[0].message)).toEqual(["tool-call"]);
|
|
497
|
+
expect(contentTypes(res.messages[1].message)).toEqual(["tool-result"]);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
262
502
|
describe("autoDenyUnresolvedApprovals", () => {
|
|
263
503
|
test("returns messages unchanged when no unresolved approvals", () => {
|
|
264
504
|
const messages = [
|
|
@@ -267,13 +507,21 @@ describe("mapping", () => {
|
|
|
267
507
|
role: "assistant" as const,
|
|
268
508
|
content: [
|
|
269
509
|
{ type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
|
|
270
|
-
{
|
|
510
|
+
{
|
|
511
|
+
type: "tool-approval-request",
|
|
512
|
+
approvalId: "ap1",
|
|
513
|
+
toolCallId: "tc1",
|
|
514
|
+
},
|
|
271
515
|
],
|
|
272
516
|
},
|
|
273
517
|
{
|
|
274
518
|
role: "tool" as const,
|
|
275
519
|
content: [
|
|
276
|
-
{
|
|
520
|
+
{
|
|
521
|
+
type: "tool-approval-response",
|
|
522
|
+
approvalId: "ap1",
|
|
523
|
+
approved: true,
|
|
524
|
+
},
|
|
277
525
|
],
|
|
278
526
|
},
|
|
279
527
|
] as any;
|
|
@@ -289,7 +537,11 @@ describe("mapping", () => {
|
|
|
289
537
|
role: "assistant" as const,
|
|
290
538
|
content: [
|
|
291
539
|
{ type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
|
|
292
|
-
{
|
|
540
|
+
{
|
|
541
|
+
type: "tool-approval-request",
|
|
542
|
+
approvalId: "ap1",
|
|
543
|
+
toolCallId: "tc1",
|
|
544
|
+
},
|
|
293
545
|
],
|
|
294
546
|
},
|
|
295
547
|
{ role: "user" as const, content: "new message" },
|
|
@@ -304,7 +556,9 @@ describe("mapping", () => {
|
|
|
304
556
|
expect(denialContent[0].type).toBe("tool-approval-response");
|
|
305
557
|
expect(denialContent[0].approvalId).toBe("ap1");
|
|
306
558
|
expect(denialContent[0].approved).toBe(false);
|
|
307
|
-
expect(denialContent[0].reason).toBe(
|
|
559
|
+
expect(denialContent[0].reason).toBe(
|
|
560
|
+
"auto-denied: new generation started",
|
|
561
|
+
);
|
|
308
562
|
// The new user message should follow
|
|
309
563
|
expect(result[3].role).toBe("user");
|
|
310
564
|
expect(result[3].content).toBe("new message");
|
|
@@ -317,8 +571,16 @@ describe("mapping", () => {
|
|
|
317
571
|
content: [
|
|
318
572
|
{ type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
|
|
319
573
|
{ type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
|
|
320
|
-
{
|
|
321
|
-
|
|
574
|
+
{
|
|
575
|
+
type: "tool-approval-request",
|
|
576
|
+
approvalId: "ap1",
|
|
577
|
+
toolCallId: "tc1",
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
type: "tool-approval-request",
|
|
581
|
+
approvalId: "ap2",
|
|
582
|
+
toolCallId: "tc2",
|
|
583
|
+
},
|
|
322
584
|
],
|
|
323
585
|
},
|
|
324
586
|
] as any;
|
|
@@ -341,14 +603,26 @@ describe("mapping", () => {
|
|
|
341
603
|
content: [
|
|
342
604
|
{ type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
|
|
343
605
|
{ type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
|
|
344
|
-
{
|
|
345
|
-
|
|
606
|
+
{
|
|
607
|
+
type: "tool-approval-request",
|
|
608
|
+
approvalId: "ap1",
|
|
609
|
+
toolCallId: "tc1",
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
type: "tool-approval-request",
|
|
613
|
+
approvalId: "ap2",
|
|
614
|
+
toolCallId: "tc2",
|
|
615
|
+
},
|
|
346
616
|
],
|
|
347
617
|
},
|
|
348
618
|
{
|
|
349
619
|
role: "tool" as const,
|
|
350
620
|
content: [
|
|
351
|
-
{
|
|
621
|
+
{
|
|
622
|
+
type: "tool-approval-response",
|
|
623
|
+
approvalId: "ap1",
|
|
624
|
+
approved: true,
|
|
625
|
+
},
|
|
352
626
|
],
|
|
353
627
|
},
|
|
354
628
|
{ role: "user" as const, content: "next question" },
|
|
@@ -381,8 +655,16 @@ describe("mapping", () => {
|
|
|
381
655
|
content: [
|
|
382
656
|
{ type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
|
|
383
657
|
{ type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
|
|
384
|
-
{
|
|
385
|
-
|
|
658
|
+
{
|
|
659
|
+
type: "tool-approval-request",
|
|
660
|
+
approvalId: "ap1",
|
|
661
|
+
toolCallId: "tc1",
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
type: "tool-approval-request",
|
|
665
|
+
approvalId: "ap2",
|
|
666
|
+
toolCallId: "tc2",
|
|
667
|
+
},
|
|
386
668
|
],
|
|
387
669
|
},
|
|
388
670
|
] as any;
|
|
@@ -390,12 +672,8 @@ describe("mapping", () => {
|
|
|
390
672
|
autoDenyUnresolvedApprovals(messages);
|
|
391
673
|
|
|
392
674
|
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
393
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
394
|
-
|
|
395
|
-
);
|
|
396
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
397
|
-
expect.stringContaining("ap2"),
|
|
398
|
-
);
|
|
675
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("ap1"));
|
|
676
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("ap2"));
|
|
399
677
|
warnSpy.mockRestore();
|
|
400
678
|
});
|
|
401
679
|
});
|
package/src/mapping.ts
CHANGED
|
@@ -313,24 +313,30 @@ export async function serializeResponseMessages<TOOLS extends ToolSet>(
|
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
/**
|
|
316
|
-
* Serialize the new messages
|
|
317
|
-
*
|
|
316
|
+
* Serialize the new response messages produced by this step.
|
|
317
|
+
*
|
|
318
|
+
* `step.response.messages` is cumulative across steps in AI SDK v6 — each
|
|
319
|
+
* step's array contains all messages from prior steps too. Pass
|
|
320
|
+
* `previousResponseMessageCount` (the prior step's `response.messages.length`,
|
|
321
|
+
* or `0` for the first step) so we slice only the new tail. The parameter is
|
|
322
|
+
* required: defaulting it would silently duplicate every prior message on
|
|
323
|
+
* every multi-step save.
|
|
318
324
|
*/
|
|
319
325
|
export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
|
|
320
326
|
ctx: ActionCtx,
|
|
321
327
|
component: AgentComponent,
|
|
322
328
|
step: StepResult<TOOLS>,
|
|
323
329
|
model: ModelOrMetadata | undefined,
|
|
330
|
+
previousResponseMessageCount: number,
|
|
324
331
|
): Promise<{ messages: MessageWithMetadata[] }> {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
332
|
+
const newMessages = step.response.messages.slice(previousResponseMessageCount);
|
|
333
|
+
// Keep at least one message in the output so the step still anchors an
|
|
334
|
+
// order slot — downstream `addMessages` relies on each step contributing a
|
|
335
|
+
// row even when AI SDK produced no response messages.
|
|
336
|
+
const messagesToSerialize: ModelMessage[] =
|
|
337
|
+
newMessages.length > 0
|
|
338
|
+
? newMessages
|
|
339
|
+
: [{ role: "assistant" as const, content: [] }];
|
|
334
340
|
return serializeStepMessages(ctx, component, step, model, messagesToSerialize);
|
|
335
341
|
}
|
|
336
342
|
|
|
@@ -4,10 +4,12 @@ import { type UIDataTypes, type UIMessageChunk, type UITools } from "ai";
|
|
|
4
4
|
import type { StreamQuery, StreamQueryArgs } from "./types.js";
|
|
5
5
|
import { type UIMessage } from "../UIMessages.js";
|
|
6
6
|
import {
|
|
7
|
+
applyUIMessageChunksIncremental,
|
|
7
8
|
blankUIMessage,
|
|
9
|
+
emptyIncrementalStreamState,
|
|
8
10
|
getParts,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
statusFromStreamStatus,
|
|
12
|
+
type IncrementalStreamState,
|
|
11
13
|
} from "../deltas.js";
|
|
12
14
|
import { useDeltaStreams } from "./useDeltaStreams.js";
|
|
13
15
|
|
|
@@ -53,6 +55,7 @@ export function useStreamingUIMessages<
|
|
|
53
55
|
{
|
|
54
56
|
uiMessage: UIMessage<METADATA, DATA_PARTS, TOOLS>;
|
|
55
57
|
cursor: number;
|
|
58
|
+
streamState: IncrementalStreamState;
|
|
56
59
|
}
|
|
57
60
|
>
|
|
58
61
|
>({});
|
|
@@ -63,16 +66,23 @@ export function useStreamingUIMessages<
|
|
|
63
66
|
|
|
64
67
|
useEffect(() => {
|
|
65
68
|
if (!streams) return;
|
|
66
|
-
// return if there are no new deltas beyond the cursors
|
|
67
69
|
let noNewDeltas = true;
|
|
68
70
|
for (const stream of streams) {
|
|
69
|
-
const
|
|
70
|
-
const cursor =
|
|
71
|
-
if (
|
|
71
|
+
const existingStreamState = messageState[stream.streamMessage.streamId];
|
|
72
|
+
const cursor = existingStreamState?.cursor;
|
|
73
|
+
if (existingStreamState === undefined || cursor === undefined) {
|
|
72
74
|
noNewDeltas = false;
|
|
73
75
|
break;
|
|
74
76
|
}
|
|
75
|
-
if (
|
|
77
|
+
if (stream.deltas.some((d) => d.parts.length > 0 && d.end > cursor)) {
|
|
78
|
+
noNewDeltas = false;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
existingStreamState &&
|
|
83
|
+
existingStreamState.uiMessage.status !==
|
|
84
|
+
statusFromStreamStatus(stream.streamMessage.status)
|
|
85
|
+
) {
|
|
76
86
|
noNewDeltas = false;
|
|
77
87
|
break;
|
|
78
88
|
}
|
|
@@ -87,40 +97,58 @@ export function useStreamingUIMessages<
|
|
|
87
97
|
{
|
|
88
98
|
uiMessage: UIMessage<METADATA, DATA_PARTS, TOOLS>;
|
|
89
99
|
cursor: number;
|
|
100
|
+
streamState: IncrementalStreamState;
|
|
90
101
|
}
|
|
91
102
|
> = Object.fromEntries(
|
|
92
103
|
await Promise.all(
|
|
93
104
|
streams.map(async ({ deltas, streamMessage }) => {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
[
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
105
|
+
const streamId = streamMessage.streamId;
|
|
106
|
+
const existing = messageState[streamId];
|
|
107
|
+
const fromCursor = existing?.cursor ?? 0;
|
|
108
|
+
const status = statusFromStreamStatus(streamMessage.status);
|
|
109
|
+
const prevState =
|
|
110
|
+
existing?.streamState ?? emptyIncrementalStreamState();
|
|
111
|
+
|
|
112
|
+
const { parts: newParts, cursor } = getParts<UIMessageChunk>(
|
|
113
|
+
deltas,
|
|
114
|
+
fromCursor,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const base =
|
|
118
|
+
existing?.uiMessage ??
|
|
119
|
+
blankUIMessage(streamMessage, threadId as string);
|
|
120
|
+
|
|
121
|
+
if (newParts.length === 0) {
|
|
122
|
+
if (existing && existing.uiMessage.status !== status) {
|
|
123
|
+
return [
|
|
124
|
+
streamId,
|
|
125
|
+
{
|
|
126
|
+
uiMessage: { ...existing.uiMessage, status },
|
|
127
|
+
cursor: existing.cursor,
|
|
128
|
+
streamState: prevState,
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
}
|
|
116
132
|
return [
|
|
117
|
-
|
|
118
|
-
{
|
|
119
|
-
uiMessage: uiMessages[0],
|
|
120
|
-
cursor,
|
|
121
|
-
},
|
|
133
|
+
streamId,
|
|
134
|
+
existing ?? { uiMessage: base, cursor: 0, streamState: prevState },
|
|
122
135
|
];
|
|
123
136
|
}
|
|
137
|
+
|
|
138
|
+
const { message, streamState } = applyUIMessageChunksIncremental(
|
|
139
|
+
base as UIMessage,
|
|
140
|
+
newParts,
|
|
141
|
+
prevState,
|
|
142
|
+
);
|
|
143
|
+
message.status = status;
|
|
144
|
+
return [
|
|
145
|
+
streamId,
|
|
146
|
+
{
|
|
147
|
+
uiMessage: message as UIMessage<METADATA, DATA_PARTS, TOOLS>,
|
|
148
|
+
cursor,
|
|
149
|
+
streamState,
|
|
150
|
+
},
|
|
151
|
+
];
|
|
124
152
|
}),
|
|
125
153
|
),
|
|
126
154
|
);
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
dedupeMessages,
|
|
4
|
+
mergeUIMessages,
|
|
5
|
+
type UIMessageLike,
|
|
6
|
+
} from "./useUIMessages.js";
|
|
3
7
|
|
|
4
8
|
type TestMessage = {
|
|
5
9
|
order: number;
|
|
@@ -8,6 +12,39 @@ type TestMessage = {
|
|
|
8
12
|
id: string;
|
|
9
13
|
};
|
|
10
14
|
|
|
15
|
+
type TestUIMessage = UIMessageLike & {
|
|
16
|
+
id: string;
|
|
17
|
+
key: string;
|
|
18
|
+
text: string;
|
|
19
|
+
_creationTime: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function testUIMessage({
|
|
23
|
+
id,
|
|
24
|
+
order,
|
|
25
|
+
stepOrder,
|
|
26
|
+
status,
|
|
27
|
+
text,
|
|
28
|
+
}: {
|
|
29
|
+
id: string;
|
|
30
|
+
order: number;
|
|
31
|
+
stepOrder: number;
|
|
32
|
+
status: TestUIMessage["status"];
|
|
33
|
+
text: string;
|
|
34
|
+
}): TestUIMessage {
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
key: `thread-${order}-${stepOrder}`,
|
|
38
|
+
order,
|
|
39
|
+
stepOrder,
|
|
40
|
+
status,
|
|
41
|
+
role: "assistant",
|
|
42
|
+
parts: [{ type: "text", text }],
|
|
43
|
+
text,
|
|
44
|
+
_creationTime: 0,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
11
48
|
describe("dedupeMessages", () => {
|
|
12
49
|
it("should prefer messages from messages list when streaming messages are absent", () => {
|
|
13
50
|
const messages: TestMessage[] = [
|
|
@@ -253,3 +290,45 @@ describe("dedupeMessages", () => {
|
|
|
253
290
|
expect(result[0].id).toBe("messages-success");
|
|
254
291
|
});
|
|
255
292
|
});
|
|
293
|
+
|
|
294
|
+
describe("mergeUIMessages", () => {
|
|
295
|
+
it("dedupes streaming steps before combining assistant messages", () => {
|
|
296
|
+
const messages = [
|
|
297
|
+
testUIMessage({
|
|
298
|
+
id: "persisted-step-1",
|
|
299
|
+
order: 1,
|
|
300
|
+
stepOrder: 1,
|
|
301
|
+
status: "success",
|
|
302
|
+
text: "7",
|
|
303
|
+
}),
|
|
304
|
+
testUIMessage({
|
|
305
|
+
id: "persisted-step-2",
|
|
306
|
+
order: 1,
|
|
307
|
+
stepOrder: 2,
|
|
308
|
+
status: "success",
|
|
309
|
+
text: "8",
|
|
310
|
+
}),
|
|
311
|
+
];
|
|
312
|
+
const streamMessages = [
|
|
313
|
+
testUIMessage({
|
|
314
|
+
id: "stream-step-2",
|
|
315
|
+
order: 1,
|
|
316
|
+
stepOrder: 2,
|
|
317
|
+
status: "streaming",
|
|
318
|
+
text: "8",
|
|
319
|
+
}),
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
const result = mergeUIMessages(messages, streamMessages);
|
|
323
|
+
|
|
324
|
+
expect(result).toHaveLength(1);
|
|
325
|
+
expect(result[0].id).toBe("persisted-step-1");
|
|
326
|
+
expect(result[0].stepOrder).toBe(1);
|
|
327
|
+
expect(result[0].status).toBe("success");
|
|
328
|
+
expect(result[0].text).toBe("7 8");
|
|
329
|
+
expect(result[0].parts).toEqual([
|
|
330
|
+
{ type: "text", text: "7" },
|
|
331
|
+
{ type: "text", text: "8" },
|
|
332
|
+
]);
|
|
333
|
+
});
|
|
334
|
+
});
|