@convex-dev/agent 0.2.8-alpha.1 → 0.2.8-alpha.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.
@@ -1,41 +1,175 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
+ blankUIMessage,
3
4
  deriveUIMessagesFromTextStreamParts,
4
5
  updateFromTextStreamParts,
6
+ updateFromUIMessageChunks,
5
7
  } from "./deltas.js";
6
8
  import type { StreamMessage, StreamDelta } from "./validators.js";
7
9
  import { omit } from "convex-helpers";
8
- import type { TextStreamPart, ToolSet, ToolUIPart } from "ai";
10
+ import type { Tool, ToolUIPart, TypedToolResult } from "ai";
9
11
 
10
- function makeStreamMessage(
11
- streamId: string,
12
- order: number,
13
- stepOrder: number,
14
- ): StreamMessage {
15
- return { streamId, order, stepOrder } as StreamMessage;
16
- }
12
+ describe("UIMessageChunks", () => {
13
+ it("updates a UIMessage with a tool call and follow up", async () => {
14
+ const uiMessage = blankUIMessage(
15
+ {
16
+ streamId: "s1",
17
+ status: "streaming",
18
+ order: 0,
19
+ stepOrder: 1,
20
+ format: "UIMessageChunk",
21
+ agentName: "agent1",
22
+ },
23
+ "thread1",
24
+ );
25
+ expect(uiMessage.text).toBe("");
26
+ expect(uiMessage.parts).toEqual([]);
27
+ const updatedMessage = await updateFromUIMessageChunks(uiMessage, [
28
+ { type: "start" },
29
+ { type: "start-step" },
30
+ { type: "reasoning-start", id: "reasoning-0" },
31
+ { type: "reasoning-delta", id: "reasoning-0", delta: "Okay" },
32
+ {
33
+ type: "reasoning-delta",
34
+ id: "reasoning-0",
35
+ delta: ", the user is asking...",
36
+ },
37
+ { type: "text-start", id: "txt-1" },
38
+ {
39
+ type: "text-delta",
40
+ id: "txt-1",
41
+ delta: "Hey ho.",
42
+ },
43
+ { type: "reasoning-end", id: "reasoning-0" },
44
+ { type: "text-end", id: "txt-1" },
45
+ { type: "tool-input-start", toolCallId: "0ychh9k6f", toolName: "say" },
46
+ {
47
+ type: "tool-input-delta",
48
+ toolCallId: "0ychh9k6f",
49
+ inputTextDelta:
50
+ '{"question":"What is your favorite flavor of ice cream?"}',
51
+ },
52
+ {
53
+ type: "tool-input-available",
54
+ toolCallId: "0ychh9k6f",
55
+ toolName: "say",
56
+ input: { question: "What is your favorite flavor of ice cream?" },
57
+ providerMetadata: { openai: { itemId: "123" } },
58
+ },
59
+ {
60
+ type: "tool-output-available",
61
+ toolCallId: "0ychh9k6f",
62
+ output: "I'm sorry I can't help you. Stop asking me questions.",
63
+ },
64
+ { type: "finish-step" },
65
+ { type: "start-step" },
66
+ { type: "tool-input-start", toolCallId: "1ychh9k6f", toolName: "say" },
67
+ {
68
+ type: "tool-input-delta",
69
+ toolCallId: "1ychh9k6f",
70
+ inputTextDelta:
71
+ '{"question":"What is your favorite flavor of ice cream?"}',
72
+ },
73
+ {
74
+ type: "tool-input-available",
75
+ toolCallId: "1ychh9k6f",
76
+ toolName: "say",
77
+ input: { question: "What is your favorite flavor of ice cream?" },
78
+ },
79
+ {
80
+ type: "tool-output-available",
81
+ toolCallId: "1ychh9k6f",
82
+ output: "I'm serious.",
83
+ },
84
+ { type: "finish-step" },
85
+ { type: "start-step" },
86
+ { type: "text-start", id: "msg_0" },
87
+ {
88
+ type: "text-delta",
89
+ id: "msg_0",
90
+ delta: "The best ice cream flavor is vanilla",
91
+ },
92
+ {
93
+ type: "text-delta",
94
+ id: "msg_0",
95
+ delta: ".",
96
+ },
97
+ { type: "text-end", id: "msg_0" },
98
+ { type: "finish-step" },
99
+ { type: "finish" },
100
+ ]);
101
+ expect(updatedMessage.text).toBe(
102
+ "Hey ho.The best ice cream flavor is vanilla.",
103
+ );
104
+ const expectedParts = [
105
+ {
106
+ type: "step-start",
107
+ },
108
+ {
109
+ state: "done",
110
+ text: "Okay, the user is asking...",
111
+ type: "reasoning",
112
+ },
113
+ {
114
+ state: "done",
115
+ text: "Hey ho.",
116
+ type: "text",
117
+ },
118
+ {
119
+ callProviderMetadata: {
120
+ openai: {
121
+ itemId: "123",
122
+ },
123
+ },
124
+ input: {
125
+ question: "What is your favorite flavor of ice cream?",
126
+ },
127
+ output: "I'm sorry I can't help you. Stop asking me questions.",
17
128
 
18
- function makeDelta(
19
- streamId: string,
20
- start: number,
21
- end: number,
22
- parts: TextStreamPart<ToolSet>[],
23
- ): StreamDelta {
24
- return { streamId, start, end, parts };
25
- }
129
+ state: "output-available",
130
+ toolCallId: "0ychh9k6f",
131
+ type: "tool-say",
132
+ },
133
+ {
134
+ type: "step-start",
135
+ },
136
+ {
137
+ input: {
138
+ question: "What is your favorite flavor of ice cream?",
139
+ },
140
+ output: "I'm serious.",
141
+ state: "output-available",
142
+ toolCallId: "1ychh9k6f",
143
+ type: "tool-say",
144
+ },
145
+ {
146
+ type: "step-start",
147
+ },
148
+ {
149
+ state: "done",
150
+ text: "The best ice cream flavor is vanilla.",
151
+ type: "text",
152
+ },
153
+ ];
154
+ expect(updatedMessage.parts).toEqual(expectedParts);
155
+ expect(updatedMessage.parts).toHaveLength(8);
156
+ });
157
+ });
26
158
 
27
159
  describe("mergeDeltas", () => {
28
160
  it("merges a single text-delta into a message", () => {
29
161
  const streamId = "s1";
30
- const streamMessages = [makeStreamMessage(streamId, 1, 0)];
31
162
  const deltas = [
32
- makeDelta(streamId, 0, 5, [
33
- { type: "text-delta", id: "1", text: "Hello" },
34
- ]),
163
+ {
164
+ streamId,
165
+ start: 0,
166
+ end: 5,
167
+ parts: [{ type: "text-delta", id: "1", text: "Hello" }],
168
+ } satisfies StreamDelta,
35
169
  ];
36
170
  const [messages, newStreams, changed] = deriveUIMessagesFromTextStreamParts(
37
171
  "thread1",
38
- streamMessages,
172
+ [{ streamId, order: 1, stepOrder: 0, status: "streaming" }],
39
173
  [],
40
174
  deltas,
41
175
  );
@@ -48,18 +182,23 @@ describe("mergeDeltas", () => {
48
182
 
49
183
  it("merges multiple deltas for the same stream", () => {
50
184
  const streamId = "s1";
51
- const streamMessages = [makeStreamMessage(streamId, 1, 0)];
52
185
  const deltas = [
53
- makeDelta(streamId, 0, 5, [
54
- { type: "text-delta", id: "1", text: "Hello" },
55
- ]),
56
- makeDelta(streamId, 5, 11, [
57
- { type: "text-delta", id: "2", text: " World!" },
58
- ]),
186
+ {
187
+ streamId,
188
+ start: 0,
189
+ end: 5,
190
+ parts: [{ type: "text-delta", id: "1", text: "Hello" }],
191
+ },
192
+ {
193
+ streamId,
194
+ start: 5,
195
+ end: 11,
196
+ parts: [{ type: "text-delta", id: "2", text: " World!" }],
197
+ },
59
198
  ];
60
199
  const [messages, newStreams, changed] = deriveUIMessagesFromTextStreamParts(
61
200
  "thread1",
62
- streamMessages,
201
+ [{ streamId, order: 1, stepOrder: 0, status: "streaming" }],
63
202
  [],
64
203
  deltas,
65
204
  );
@@ -71,29 +210,38 @@ describe("mergeDeltas", () => {
71
210
 
72
211
  it("handles tool-call and tool-result parts", () => {
73
212
  const streamId = "s2";
74
- const streamMessages = [makeStreamMessage(streamId, 2, 0)];
75
213
  const deltas = [
76
- makeDelta(streamId, 0, 1, [
77
- {
78
- type: "tool-call",
79
- toolCallId: "call1",
80
- toolName: "myTool",
81
- input: "What's the meaning of life?",
82
- },
83
- ]),
84
- makeDelta(streamId, 1, 2, [
85
- {
86
- type: "tool-result",
87
- toolCallId: "call1",
88
- toolName: "myTool",
89
- input: undefined,
90
- output: "42",
91
- },
92
- ]),
214
+ {
215
+ streamId,
216
+ start: 0,
217
+ end: 1,
218
+ parts: [
219
+ {
220
+ type: "tool-call",
221
+ toolCallId: "call1",
222
+ toolName: "myTool",
223
+ input: "What's the meaning of life?",
224
+ },
225
+ ],
226
+ } satisfies StreamDelta,
227
+ {
228
+ streamId,
229
+ start: 1,
230
+ end: 2,
231
+ parts: [
232
+ {
233
+ type: "tool-result",
234
+ toolCallId: "call1",
235
+ toolName: "myTool",
236
+ input: undefined,
237
+ output: "42",
238
+ } satisfies TypedToolResult<{ myTool: Tool }>,
239
+ ],
240
+ } satisfies StreamDelta,
93
241
  ];
94
242
  const [[message], _, changed] = deriveUIMessagesFromTextStreamParts(
95
243
  "thread1",
96
- streamMessages,
244
+ [{ streamId, order: 2, stepOrder: 0, status: "streaming" }],
97
245
  [],
98
246
  deltas,
99
247
  );
@@ -114,11 +262,10 @@ describe("mergeDeltas", () => {
114
262
 
115
263
  it("returns changed=false if no new deltas", () => {
116
264
  const streamId = "s3";
117
- const streamMessages = [makeStreamMessage(streamId, 3, 0)];
118
265
  const deltas: StreamDelta[] = [];
119
266
  const [, newStreams, changed] = deriveUIMessagesFromTextStreamParts(
120
267
  "thread1",
121
- streamMessages,
268
+ [{ streamId, order: 3, stepOrder: 0, status: "streaming" }],
122
269
  [],
123
270
  deltas,
124
271
  );
@@ -127,15 +274,26 @@ describe("mergeDeltas", () => {
127
274
  });
128
275
 
129
276
  it("handles multiple streams and sorts by order/stepOrder", () => {
130
- const s1 = makeStreamMessage("s1", 1, 0);
131
- const s2 = makeStreamMessage("s2", 2, 0);
132
277
  const deltas = [
133
- makeDelta("s2", 0, 3, [{ type: "text-delta", id: "1", text: "B" }]),
134
- makeDelta("s1", 0, 3, [{ type: "text-delta", id: "2", text: "A" }]),
278
+ {
279
+ streamId: "s2",
280
+ start: 0,
281
+ end: 3,
282
+ parts: [{ type: "text-delta", id: "1", text: "B" }],
283
+ } satisfies StreamDelta,
284
+ {
285
+ streamId: "s1",
286
+ start: 0,
287
+ end: 3,
288
+ parts: [{ type: "text-delta", id: "2", text: "A" }],
289
+ } satisfies StreamDelta,
135
290
  ];
136
291
  const [messages, _, changed] = deriveUIMessagesFromTextStreamParts(
137
292
  "thread1",
138
- [s2, s1],
293
+ [
294
+ { streamId: "s1", order: 1, stepOrder: 0, status: "streaming" },
295
+ { streamId: "s2", order: 2, stepOrder: 0, status: "streaming" },
296
+ ],
139
297
  [],
140
298
  deltas,
141
299
  );
@@ -150,19 +308,29 @@ describe("mergeDeltas", () => {
150
308
 
151
309
  it("does not duplicate text content when merging sequential text-deltas", () => {
152
310
  const streamId = "s4";
153
- const streamMessages = [makeStreamMessage(streamId, 4, 0)];
154
311
  const deltas = [
155
- makeDelta(streamId, 0, 5, [
156
- { type: "text-delta", id: "1", text: "Hello" },
157
- ]),
158
- makeDelta(streamId, 5, 11, [
159
- { type: "text-delta", id: "2", text: " World!" },
160
- ]),
161
- makeDelta(streamId, 11, 12, [{ type: "text-delta", id: "3", text: "!" }]),
162
- ];
312
+ {
313
+ streamId,
314
+ start: 0,
315
+ end: 5,
316
+ parts: [{ type: "text-delta", id: "1", text: "Hello" }],
317
+ },
318
+ {
319
+ streamId,
320
+ start: 5,
321
+ end: 11,
322
+ parts: [{ type: "text-delta", id: "2", text: " World!" }],
323
+ },
324
+ {
325
+ streamId,
326
+ start: 11,
327
+ end: 12,
328
+ parts: [{ type: "text-delta", id: "3", text: "!" }],
329
+ },
330
+ ] satisfies StreamDelta[];
163
331
  const [messages] = deriveUIMessagesFromTextStreamParts(
164
332
  "thread1",
165
- streamMessages,
333
+ [{ streamId, order: 4, stepOrder: 0, status: "streaming" }],
166
334
  [],
167
335
  deltas,
168
336
  );
@@ -179,20 +347,34 @@ describe("mergeDeltas", () => {
179
347
 
180
348
  it("does not duplicate reasoning parts", () => {
181
349
  const streamId = "s6";
182
- const streamMessages = [makeStreamMessage(streamId, 6, 0)];
183
350
  const deltas = [
184
- makeDelta(streamId, 0, 1, [
185
- { type: "reasoning-start", id: "1" },
186
- { type: "reasoning-delta", id: "1", text: "I'm thinking..." },
187
- ]),
188
- makeDelta(streamId, 1, 2, [
189
- { type: "reasoning-delta", id: "1", text: " Still thinking..." },
190
- ]),
191
- makeDelta(streamId, 2, 3, [{ type: "reasoning-end", id: "1" }]),
351
+ {
352
+ streamId,
353
+ start: 0,
354
+ end: 1,
355
+ parts: [
356
+ { type: "reasoning-start", id: "1" },
357
+ { type: "reasoning-delta", id: "1", text: "I'm thinking..." },
358
+ ],
359
+ },
360
+ {
361
+ streamId,
362
+ start: 1,
363
+ end: 2,
364
+ parts: [
365
+ { type: "reasoning-delta", id: "1", text: " Still thinking..." },
366
+ ],
367
+ },
368
+ {
369
+ streamId,
370
+ start: 2,
371
+ end: 3,
372
+ parts: [{ type: "reasoning-end", id: "1" }],
373
+ },
192
374
  ];
193
375
  const [messages] = deriveUIMessagesFromTextStreamParts(
194
376
  "thread1",
195
- streamMessages,
377
+ [{ streamId, order: 6, stepOrder: 0, status: "streaming" }],
196
378
  [],
197
379
  deltas,
198
380
  );
@@ -209,14 +391,25 @@ describe("mergeDeltas", () => {
209
391
 
210
392
  it("applyDeltasToStreamMessage is idempotent and does not duplicate content", () => {
211
393
  const streamId = "s7";
212
- const streamMessage = makeStreamMessage(streamId, 7, 0);
394
+ const streamMessage = {
395
+ streamId,
396
+ order: 7,
397
+ stepOrder: 0,
398
+ status: "streaming",
399
+ } satisfies StreamMessage;
213
400
  const deltas = [
214
- makeDelta(streamId, 0, 5, [
215
- { type: "text-delta", id: "1", text: "Hello" },
216
- ]),
217
- makeDelta(streamId, 5, 11, [
218
- { type: "text-delta", id: "2", text: " World!" },
219
- ]),
401
+ {
402
+ streamId,
403
+ start: 0,
404
+ end: 5,
405
+ parts: [{ type: "text-delta", id: "1", text: "Hello" }],
406
+ },
407
+ {
408
+ streamId,
409
+ start: 5,
410
+ end: 11,
411
+ parts: [{ type: "text-delta", id: "2", text: " World!" }],
412
+ },
220
413
  ];
221
414
  // First call: apply both deltas
222
415
  let [result, changed] = updateFromTextStreamParts(
@@ -237,7 +430,12 @@ describe("mergeDeltas", () => {
237
430
  // Third call: add a new delta
238
431
  const moreDeltas = [
239
432
  ...deltas,
240
- makeDelta(streamId, 11, 12, [{ type: "text-delta", id: "3", text: "!" }]),
433
+ {
434
+ streamId,
435
+ start: 11,
436
+ end: 12,
437
+ parts: [{ type: "text-delta", id: "3", text: "!" }],
438
+ },
241
439
  ];
242
440
  [result, changed] = updateFromTextStreamParts(
243
441
  "thread1",
@@ -260,14 +458,22 @@ describe("mergeDeltas", () => {
260
458
 
261
459
  it("mergeDeltas is pure and does not mutate inputs", () => {
262
460
  const streamId = "s8";
263
- const streamMessages = [makeStreamMessage(streamId, 8, 0)];
461
+ const streamMessages = [
462
+ { streamId, order: 8, stepOrder: 0, status: "streaming" },
463
+ ] satisfies StreamMessage[];
264
464
  const deltas = [
265
- makeDelta(streamId, 0, 5, [
266
- { type: "text-delta", id: "1", text: "Hello" },
267
- ]),
268
- makeDelta(streamId, 5, 11, [
269
- { type: "text-delta", id: "2", text: " World!" },
270
- ]),
465
+ {
466
+ streamId,
467
+ start: 0,
468
+ end: 5,
469
+ parts: [{ type: "text-delta", id: "1", text: "Hello" }],
470
+ },
471
+ {
472
+ streamId,
473
+ start: 5,
474
+ end: 11,
475
+ parts: [{ type: "text-delta", id: "2", text: " World!" }],
476
+ },
271
477
  ];
272
478
  // Deep freeze inputs to catch mutation
273
479
  function deepFreeze(obj: unknown): unknown {
@@ -309,14 +515,22 @@ describe("mergeDeltas", () => {
309
515
  );
310
516
  expect(changed1).toBe(changed2);
311
517
  // Inputs should remain unchanged
312
- expect(streamMessages).toEqual([makeStreamMessage(streamId, 8, 0)]);
518
+ expect(streamMessages).toMatchObject([
519
+ { streamId, order: 8, stepOrder: 0, status: "streaming" },
520
+ ]);
313
521
  expect(deltas).toEqual([
314
- makeDelta(streamId, 0, 5, [
315
- { type: "text-delta", id: "1", text: "Hello" },
316
- ]),
317
- makeDelta(streamId, 5, 11, [
318
- { type: "text-delta", id: "2", text: " World!" },
319
- ]),
522
+ {
523
+ streamId,
524
+ start: 0,
525
+ end: 5,
526
+ parts: [{ type: "text-delta", id: "1", text: "Hello" }],
527
+ },
528
+ {
529
+ streamId,
530
+ start: 5,
531
+ end: 11,
532
+ parts: [{ type: "text-delta", id: "2", text: " World!" }],
533
+ },
320
534
  ]);
321
535
  });
322
536
  });
@@ -61,6 +61,11 @@ export function useStreamingUIMessages<
61
61
  Map<string, ReadableStreamDefaultController<UIMessageChunk>>
62
62
  >(new Map());
63
63
 
64
+ const startOrder = options?.startOrder
65
+ ? // round down to the nearest 10 for some cache benefits
66
+ options.startOrder - (options.startOrder % 10)
67
+ : 0;
68
+
64
69
  // Get all the active streams
65
70
  const streamList = useQuery(
66
71
  query,
@@ -70,13 +75,20 @@ export function useStreamingUIMessages<
70
75
  ...args,
71
76
  streamArgs: {
72
77
  kind: "list",
73
- startOrder: options?.startOrder ?? 0,
78
+ startOrder,
74
79
  } as StreamArgs,
75
80
  } as FunctionArgs<Query>),
76
81
  ) as
77
82
  | { streams: Extract<SyncStreamsReturnValue, { kind: "list" }> }
78
83
  | undefined;
79
84
 
85
+ const minOrder = Math.max(
86
+ options?.startOrder ?? 0,
87
+ streamList?.streams.messages.length
88
+ ? Math.min(...streamList.streams.messages.map(({ order }) => order))
89
+ : 0,
90
+ );
91
+
80
92
  // Get the cursors for all the active streams
81
93
  const cursors = useMemo(() => {
82
94
  if (!streamList?.streams) return [];
@@ -85,11 +97,12 @@ export function useStreamingUIMessages<
85
97
  }
86
98
  return streamList.streams.messages
87
99
  .filter(({ streamId }) => !options?.skipStreamIds?.includes(streamId))
100
+ .filter(({ order }) => order >= minOrder)
88
101
  .map(({ streamId }) => {
89
102
  const cursor = streamCursors[streamId] ?? 0;
90
103
  return { streamId, cursor };
91
104
  });
92
- }, [streamList, streamCursors, options?.skipStreamIds]);
105
+ }, [streamList, streamCursors, options?.skipStreamIds, minOrder]);
93
106
 
94
107
  // Get the deltas for all the active streams, if any.
95
108
  const cursorQuery = useQuery(
@@ -107,8 +120,26 @@ export function useStreamingUIMessages<
107
120
 
108
121
  const threadId = args === "skip" ? undefined : args.threadId;
109
122
 
110
- // Process new deltas and convert to UIMessageChunks, then use readUIMessageStream
111
123
  useEffect(() => {
124
+ const activeStreamIds = new Set(
125
+ streamList?.streams.messages.map((m) => m.streamId) ?? [],
126
+ );
127
+ // Clean up finished streams
128
+ setUIMessages((prev) => {
129
+ const toRemove = Object.keys(prev).filter(
130
+ (streamId) => !activeStreamIds.has(streamId),
131
+ );
132
+ return toRemove.length ? omit(prev, toRemove) : prev;
133
+ });
134
+ for (const [
135
+ streamId,
136
+ controller,
137
+ ] of uiMessageStreamControllers.current.entries()) {
138
+ if (!activeStreamIds.has(streamId)) {
139
+ uiMessageStreamControllers.current.delete(streamId);
140
+ controller.close();
141
+ }
142
+ }
112
143
  if (!cursorQuery?.streams?.deltas || !threadId) return;
113
144
 
114
145
  const deltasByStream = new Map<string, typeof cursorQuery.streams.deltas>();
@@ -246,27 +277,6 @@ export function useStreamingUIMessages<
246
277
  }
247
278
  }, [cursorQuery, streamCursors, threadId, streamList?.streams?.messages]);
248
279
 
249
- // Clean up finished streams
250
- useEffect(() => {
251
- const activeStreamIds = new Set(
252
- streamList?.streams.messages.map((m) => m.streamId) ?? [],
253
- );
254
- setUIMessages((prev) => {
255
- const toRemove = Object.keys(prev).filter(
256
- (streamId) => !activeStreamIds.has(streamId),
257
- );
258
- return toRemove.length ? omit(prev, toRemove) : prev;
259
- });
260
- for (const [
261
- streamId,
262
- controller,
263
- ] of uiMessageStreamControllers.current.entries()) {
264
- if (!activeStreamIds.has(streamId)) {
265
- controller.close();
266
- uiMessageStreamControllers.current.delete(streamId);
267
- }
268
- }
269
- }, [streamList]);
270
280
 
271
281
  return useMemo(() => {
272
282
  if (!streamList) return undefined;
@@ -159,6 +159,8 @@ export function useThreadMessages<
159
159
  { startOrder },
160
160
  );
161
161
 
162
+ const threadId = args === "skip" ? undefined : args.threadId;
163
+
162
164
  const merged = useMemo(() => {
163
165
  const streamListMessages =
164
166
  streamMessages?.map((m) => ({
@@ -171,10 +173,13 @@ export function useThreadMessages<
171
173
  paginated.results
172
174
  .map((m) => ({ ...m, streaming: false }))
173
175
  // Note: this is intentionally after paginated results.
174
- .concat(streamListMessages),
176
+ .concat(streamListMessages) as (MessageDocLike & {
177
+ streaming: boolean;
178
+ key: string;
179
+ })[],
175
180
  ).reduce(
176
- (msgs, msg) => {
177
- msg.key = `${msg.threadId}-${msg.order}-${msg.stepOrder}`;
181
+ (msgs, msg: MessageDocLike & { streaming: boolean; key: string }) => {
182
+ msg.key = `${threadId}-${msg.order}-${msg.stepOrder}`;
178
183
  const last = msgs.at(-1);
179
184
  if (!last) {
180
185
  return [msg];
@@ -193,10 +198,10 @@ export function useThreadMessages<
193
198
  // skip the new one if the previous one (listed) was finalized
194
199
  return msgs;
195
200
  },
196
- [] as (ThreadMessagesResult<Query> & { streaming: boolean })[],
201
+ [] as (MessageDocLike & { streaming: boolean; key: string })[],
197
202
  ),
198
203
  };
199
- }, [paginated, streamMessages]);
204
+ }, [paginated, streamMessages, threadId]);
200
205
 
201
206
  return merged as ThreadMessagesResult<Query> & {
202
207
  key: string;