@convex-dev/agent 0.2.8-alpha.2 → 0.2.8-alpha.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.
Files changed (50) hide show
  1. package/dist/UIMessages.d.ts +2 -1
  2. package/dist/UIMessages.d.ts.map +1 -1
  3. package/dist/UIMessages.js +12 -17
  4. package/dist/UIMessages.js.map +1 -1
  5. package/dist/client/createTool.d.ts +1 -1
  6. package/dist/client/createTool.d.ts.map +1 -1
  7. package/dist/client/createTool.js +1 -1
  8. package/dist/client/createTool.js.map +1 -1
  9. package/dist/client/mockModel.d.ts +10 -0
  10. package/dist/client/mockModel.d.ts.map +1 -1
  11. package/dist/client/mockModel.js +73 -62
  12. package/dist/client/mockModel.js.map +1 -1
  13. package/dist/component/streams.d.ts +8 -8
  14. package/dist/component/streams.d.ts.map +1 -1
  15. package/dist/component/streams.js +18 -21
  16. package/dist/component/streams.js.map +1 -1
  17. package/dist/deltas.d.ts.map +1 -1
  18. package/dist/deltas.js +1 -0
  19. package/dist/deltas.js.map +1 -1
  20. package/dist/react/types.d.ts +1 -1
  21. package/dist/react/types.d.ts.map +1 -1
  22. package/dist/react/useStreamingUIMessages.d.ts +2 -2
  23. package/dist/react/useStreamingUIMessages.d.ts.map +1 -1
  24. package/dist/react/useStreamingUIMessages.js +22 -18
  25. package/dist/react/useStreamingUIMessages.js.map +1 -1
  26. package/dist/react/useThreadMessages.d.ts +2 -2
  27. package/dist/react/useThreadMessages.d.ts.map +1 -1
  28. package/dist/react/useThreadMessages.js +3 -2
  29. package/dist/react/useThreadMessages.js.map +1 -1
  30. package/dist/react/useUIMessages.d.ts +10 -6
  31. package/dist/react/useUIMessages.d.ts.map +1 -1
  32. package/dist/react/useUIMessages.js +24 -34
  33. package/dist/react/useUIMessages.js.map +1 -1
  34. package/dist/shared.d.ts.map +1 -1
  35. package/dist/shared.js +7 -4
  36. package/dist/shared.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/UIMessages.ts +16 -21
  39. package/src/client/createTool.ts +2 -2
  40. package/src/client/mockModel.ts +108 -76
  41. package/src/component/streams.ts +20 -21
  42. package/src/deltas.test.ts +311 -97
  43. package/src/deltas.ts +4 -0
  44. package/src/react/types.ts +1 -1
  45. package/src/react/useStreamingUIMessages.ts +36 -28
  46. package/src/react/useThreadMessages.ts +13 -8
  47. package/src/react/useUIMessages.test.ts +255 -0
  48. package/src/react/useUIMessages.ts +38 -52
  49. package/src/shared.ts +6 -4
  50. package/src/toUIMessages.test.ts +1 -1
@@ -26,7 +26,7 @@ import type {
26
26
  MessageStatus,
27
27
  StreamArgs,
28
28
  } from "../validators.js";
29
- import type { StreamMessagesArgs, StreamQuery } from "./types.js";
29
+ import type { StreamQueryArgs, StreamQuery } from "./types.js";
30
30
  import { useStreamingUIMessages } from "./useStreamingUIMessages.js";
31
31
 
32
32
  export type MessageDocLike = {
@@ -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;
@@ -223,7 +228,7 @@ export function useStreamingThreadMessages<
223
228
  >(
224
229
  query: Query,
225
230
  args:
226
- | (StreamMessagesArgs<Query> & {
231
+ | (StreamQueryArgs<Query> & {
227
232
  /** @deprecated Pass startOrder to the next argument (third argument). */
228
233
  startOrder?: number;
229
234
  })
@@ -236,7 +241,7 @@ export function useStreamingThreadMessages<
236
241
  const queryArgs =
237
242
  args === "skip"
238
243
  ? args
239
- : (omit(args, ["startOrder"]) as unknown as StreamMessagesArgs<Query>);
244
+ : (omit(args, ["startOrder"]) as unknown as StreamQueryArgs<Query>);
240
245
  const startOrder =
241
246
  args === "skip" ? undefined : (args.startOrder ?? undefined);
242
247
  const queryOptions = { startOrder, ...options };
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { dedupeMessages } from "./useUIMessages.js";
3
+
4
+ type TestMessage = {
5
+ order: number;
6
+ stepOrder: number;
7
+ status: "pending" | "success" | "failed" | "streaming";
8
+ id: string;
9
+ };
10
+
11
+ describe("dedupeMessages", () => {
12
+ it("should prefer messages from messages list when streaming messages are absent", () => {
13
+ const messages: TestMessage[] = [
14
+ { order: 1, stepOrder: 0, status: "success", id: "msg1" },
15
+ { order: 2, stepOrder: 0, status: "success", id: "msg2" },
16
+ ];
17
+ const streamMessages: TestMessage[] = [];
18
+
19
+ const result = dedupeMessages(messages, streamMessages);
20
+
21
+ expect(result).toHaveLength(2);
22
+ expect(result).toEqual(messages);
23
+ });
24
+
25
+ it("should prefer streaming messages when messages list is empty", () => {
26
+ const messages: TestMessage[] = [];
27
+ const streamMessages: TestMessage[] = [
28
+ { order: 1, stepOrder: 0, status: "streaming", id: "stream1" },
29
+ { order: 2, stepOrder: 0, status: "streaming", id: "stream2" },
30
+ ];
31
+
32
+ const result = dedupeMessages(messages, streamMessages);
33
+
34
+ expect(result).toHaveLength(2);
35
+ expect(result).toEqual(streamMessages);
36
+ });
37
+
38
+ it("should prefer non-pending messages over pending messages", () => {
39
+ const messages: TestMessage[] = [
40
+ { order: 1, stepOrder: 0, status: "pending", id: "pending1" },
41
+ { order: 2, stepOrder: 0, status: "success", id: "success1" },
42
+ ];
43
+ const streamMessages: TestMessage[] = [
44
+ { order: 1, stepOrder: 0, status: "success", id: "finalized1" },
45
+ { order: 2, stepOrder: 0, status: "streaming", id: "streaming1" },
46
+ ];
47
+
48
+ const result = dedupeMessages(messages, streamMessages);
49
+
50
+ expect(result).toHaveLength(2);
51
+ expect(result[0]).toEqual({
52
+ order: 1,
53
+ stepOrder: 0,
54
+ status: "success",
55
+ id: "finalized1",
56
+ });
57
+ expect(result[1]).toEqual({
58
+ order: 2,
59
+ stepOrder: 0,
60
+ status: "success",
61
+ id: "success1",
62
+ });
63
+ });
64
+
65
+ it("should prefer streaming over pending messages", () => {
66
+ const messages: TestMessage[] = [
67
+ { order: 1, stepOrder: 0, status: "pending", id: "pending1" },
68
+ ];
69
+ const streamMessages: TestMessage[] = [
70
+ { order: 1, stepOrder: 0, status: "streaming", id: "streaming1" },
71
+ ];
72
+
73
+ const result = dedupeMessages(messages, streamMessages);
74
+
75
+ expect(result).toHaveLength(1);
76
+ expect(result[0]).toEqual({
77
+ order: 1,
78
+ stepOrder: 0,
79
+ status: "streaming",
80
+ id: "streaming1",
81
+ });
82
+ });
83
+
84
+ it("should prefer non-pending messages from messages list over streaming messages", () => {
85
+ const messages: TestMessage[] = [
86
+ { order: 1, stepOrder: 0, status: "success", id: "success1" },
87
+ ];
88
+ const streamMessages: TestMessage[] = [
89
+ { order: 1, stepOrder: 0, status: "streaming", id: "streaming1" },
90
+ ];
91
+
92
+ const result = dedupeMessages(messages, streamMessages);
93
+
94
+ expect(result).toHaveLength(1);
95
+ expect(result[0]).toEqual({
96
+ order: 1,
97
+ stepOrder: 0,
98
+ status: "success",
99
+ id: "success1",
100
+ });
101
+ });
102
+
103
+ it("should handle complex scenarios with multiple duplicates", () => {
104
+ const messages: TestMessage[] = [
105
+ { order: 1, stepOrder: 0, status: "pending", id: "pending1" },
106
+ { order: 2, stepOrder: 0, status: "success", id: "success2" },
107
+ { order: 3, stepOrder: 0, status: "failed", id: "failed3" },
108
+ { order: 4, stepOrder: 0, status: "success", id: "success4" },
109
+ ];
110
+ const streamMessages: TestMessage[] = [
111
+ { order: 1, stepOrder: 0, status: "streaming", id: "streaming1" },
112
+ { order: 2, stepOrder: 0, status: "streaming", id: "streaming2" },
113
+ { order: 5, stepOrder: 0, status: "streaming", id: "streaming5" },
114
+ ];
115
+
116
+ const result = dedupeMessages(messages, streamMessages);
117
+
118
+ expect(result).toHaveLength(5);
119
+ // Order 1: streaming should replace pending
120
+ expect(result[0]).toEqual({
121
+ order: 1,
122
+ stepOrder: 0,
123
+ status: "streaming",
124
+ id: "streaming1",
125
+ });
126
+ // Order 2: success should be kept over streaming
127
+ expect(result[1]).toEqual({
128
+ order: 2,
129
+ stepOrder: 0,
130
+ status: "success",
131
+ id: "success2",
132
+ });
133
+ // Order 3: failed should be kept (no streaming equivalent)
134
+ expect(result[2]).toEqual({
135
+ order: 3,
136
+ stepOrder: 0,
137
+ status: "failed",
138
+ id: "failed3",
139
+ });
140
+ // Order 4: success should be kept (no streaming equivalent)
141
+ expect(result[3]).toEqual({
142
+ order: 4,
143
+ stepOrder: 0,
144
+ status: "success",
145
+ id: "success4",
146
+ });
147
+ // Order 5: streaming should be added (no messages equivalent)
148
+ expect(result[4]).toEqual({
149
+ order: 5,
150
+ stepOrder: 0,
151
+ status: "streaming",
152
+ id: "streaming5",
153
+ });
154
+ });
155
+
156
+ it("should handle different stepOrder values correctly", () => {
157
+ const messages: TestMessage[] = [
158
+ { order: 1, stepOrder: 0, status: "pending", id: "pending1-0" },
159
+ { order: 1, stepOrder: 1, status: "success", id: "success1-1" },
160
+ ];
161
+ const streamMessages: TestMessage[] = [
162
+ { order: 1, stepOrder: 0, status: "streaming", id: "streaming1-0" },
163
+ { order: 1, stepOrder: 2, status: "streaming", id: "streaming1-2" },
164
+ ];
165
+
166
+ const result = dedupeMessages(messages, streamMessages);
167
+
168
+ expect(result).toHaveLength(3);
169
+ // Order 1, stepOrder 0: streaming should replace pending
170
+ expect(result[0]).toEqual({
171
+ order: 1,
172
+ stepOrder: 0,
173
+ status: "streaming",
174
+ id: "streaming1-0",
175
+ });
176
+ // Order 1, stepOrder 1: success should be kept
177
+ expect(result[1]).toEqual({
178
+ order: 1,
179
+ stepOrder: 1,
180
+ status: "success",
181
+ id: "success1-1",
182
+ });
183
+ // Order 1, stepOrder 2: streaming should be added
184
+ expect(result[2]).toEqual({
185
+ order: 1,
186
+ stepOrder: 2,
187
+ status: "streaming",
188
+ id: "streaming1-2",
189
+ });
190
+ });
191
+
192
+ it("should maintain proper sorting by order and stepOrder", () => {
193
+ const messages: TestMessage[] = [
194
+ { order: 3, stepOrder: 0, status: "success", id: "msg3" },
195
+ { order: 1, stepOrder: 1, status: "success", id: "msg1-1" },
196
+ { order: 1, stepOrder: 0, status: "pending", id: "msg1-0" },
197
+ ];
198
+ const streamMessages: TestMessage[] = [
199
+ { order: 2, stepOrder: 0, status: "streaming", id: "stream2" },
200
+ { order: 1, stepOrder: 0, status: "streaming", id: "stream1-0" },
201
+ ];
202
+
203
+ const result = dedupeMessages(messages, streamMessages);
204
+
205
+ expect(result).toHaveLength(4);
206
+ // Should be sorted by order, then stepOrder
207
+ expect(result[0]).toEqual({
208
+ order: 1,
209
+ stepOrder: 0,
210
+ status: "streaming",
211
+ id: "stream1-0",
212
+ });
213
+ expect(result[1]).toEqual({
214
+ order: 1,
215
+ stepOrder: 1,
216
+ status: "success",
217
+ id: "msg1-1",
218
+ });
219
+ expect(result[2]).toEqual({
220
+ order: 2,
221
+ stepOrder: 0,
222
+ status: "streaming",
223
+ id: "stream2",
224
+ });
225
+ expect(result[3]).toEqual({
226
+ order: 3,
227
+ stepOrder: 0,
228
+ status: "success",
229
+ id: "msg3",
230
+ });
231
+ });
232
+
233
+ it("should handle empty arrays", () => {
234
+ const result = dedupeMessages([], []);
235
+ expect(result).toEqual([]);
236
+ });
237
+
238
+ it("should demonstrate order dependency when messages and streamMessages have same order/stepOrder", () => {
239
+ // This test shows that the current implementation doesn't guarantee
240
+ // which message wins when they have the same order/stepOrder and status
241
+ const messages: TestMessage[] = [
242
+ { order: 1, stepOrder: 0, status: "success", id: "messages-success" },
243
+ ];
244
+ const streamMessages: TestMessage[] = [
245
+ { order: 1, stepOrder: 0, status: "success", id: "stream-success" },
246
+ ];
247
+
248
+ const result = dedupeMessages(messages, streamMessages);
249
+
250
+ expect(result).toHaveLength(1);
251
+ // The result depends on which array comes first in messages.concat(streamMessages)
252
+ // Since messages comes first, it should win when both have same status
253
+ expect(result[0].id).toBe("messages-success");
254
+ });
255
+ });
@@ -19,14 +19,14 @@ import { useMemo } from "react";
19
19
  import type { SyncStreamsReturnValue } from "../client/types.js";
20
20
  import type { MessageStatus, StreamArgs } from "../validators.js";
21
21
  import type { StreamQuery } from "./types.js";
22
- import { type UIMessage } from "../UIMessages.js";
22
+ import { type UIMessage, type UIStatus } from "../UIMessages.js";
23
23
  import { sorted } from "../shared.js";
24
24
  import { useStreamingUIMessages } from "./useStreamingUIMessages.js";
25
25
 
26
26
  export type UIMessageLike = {
27
27
  order: number;
28
28
  stepOrder: number;
29
- status: MessageStatus | "streaming";
29
+ status: UIStatus;
30
30
  parts: UIMessage["parts"];
31
31
  role: UIMessage["role"];
32
32
  };
@@ -129,10 +129,9 @@ export function useUIMessages<
129
129
  stream?: Query extends StreamQuery
130
130
  ? boolean
131
131
  : ErrorMessage<"To enable streaming, your query must take in streamArgs: vStreamArgs and return a streams object returned from syncStreams. See docs.">;
132
+ skipStreamIds?: string[];
132
133
  },
133
- ): UsePaginatedQueryResult<
134
- UIMessagesQueryResult<Query> & { streaming: boolean }
135
- > {
134
+ ): UsePaginatedQueryResult<UIMessagesQueryResult<Query>> {
136
135
  // These are full messages
137
136
  const paginated = usePaginatedQuery(
138
137
  query,
@@ -140,15 +139,9 @@ export function useUIMessages<
140
139
  { initialNumItems: options.initialNumItems },
141
140
  );
142
141
 
143
- let startOrder = paginated.results.at(-1)?.order ?? 0;
144
- for (let i = paginated.results.length - 1; i >= 0; i--) {
145
- const m = paginated.results[i];
146
- if (!m.streaming && m.status === "pending") {
147
- // round down to the nearest 10 for some cache benefits
148
- startOrder = m.order - (m.order % 10);
149
- break;
150
- }
151
- }
142
+ const startOrder = paginated.results.length
143
+ ? Math.min(...paginated.results.map((m) => m.order))
144
+ : 0;
152
145
  // These are streaming messages that will not include full messages.
153
146
  const streamMessages = useStreamingUIMessages(
154
147
  query as StreamQuery<UIMessagesQueryArgs<Query>>,
@@ -158,50 +151,43 @@ export function useUIMessages<
158
151
  ? "skip"
159
152
  : // eslint-disable-next-line @typescript-eslint/no-explicit-any
160
153
  ({ ...args, paginationOpts: { cursor: null, numItems: 0 } } as any),
161
- { startOrder },
154
+ { startOrder, skipStreamIds: options.skipStreamIds },
162
155
  );
163
156
 
164
157
  const merged = useMemo(() => {
165
- const streamListMessages =
166
- streamMessages?.map((m) => ({
167
- ...m,
168
- streaming: m.status === "streaming",
169
- })) ?? [];
170
158
  return {
171
159
  ...paginated,
172
- results: sorted(
173
- paginated.results
174
- .map((m) => ({ ...m, streaming: false }))
175
- // Note: this is intentionally after paginated results.
176
- .concat(streamListMessages),
177
- ).reduce(
178
- (msgs, msg) => {
179
- const last = msgs.at(-1);
180
- if (!last) {
181
- return [msg];
182
- }
183
- if (last.order !== msg.order || last.stepOrder !== msg.stepOrder) {
184
- return [...msgs, msg];
185
- }
186
- if (
187
- last.status === "pending" &&
188
- (msg.streaming || msg.status !== "pending")
189
- ) {
190
- // Let's prefer a streaming or finalized message over a pending
191
- // one.
192
- return [...msgs.slice(0, -1), msg];
193
- }
194
- // skip the new one if the previous one (listed) was finalized
195
- return msgs;
196
- },
197
- [] as (UIMessagesQueryResult<Query> & {
198
- streaming: boolean;
199
- })[],
200
- ),
160
+ results: dedupeMessages(paginated.results, streamMessages ?? []),
201
161
  };
202
162
  }, [paginated, streamMessages]);
203
163
 
204
- return merged as UIMessagesQueryResult<Query> & {
205
- streaming: boolean;
206
- };
164
+ return merged as UIMessagesQueryResult<Query>;
165
+ }
166
+
167
+ export function dedupeMessages<
168
+ M extends {
169
+ order: number;
170
+ stepOrder: number;
171
+ status: UIStatus;
172
+ },
173
+ >(messages: M[], streamMessages: M[]): M[] {
174
+ return sorted(messages.concat(streamMessages)).reduce((msgs, msg) => {
175
+ const last = msgs.at(-1);
176
+ if (!last) {
177
+ return [msg];
178
+ }
179
+ if (last.order !== msg.order || last.stepOrder !== msg.stepOrder) {
180
+ return [...msgs, msg];
181
+ }
182
+ if (
183
+ (last.status === "pending" || last.status === "streaming") &&
184
+ msg.status !== "pending"
185
+ ) {
186
+ // Let's prefer a streaming or finalized message over a pending
187
+ // one.
188
+ return [...msgs.slice(0, -1), msg];
189
+ }
190
+ // skip the new one if the previous one (listed) was finalized
191
+ return msgs;
192
+ }, [] as M[]);
207
193
  }
package/src/shared.ts CHANGED
@@ -25,11 +25,13 @@ export function extractText(message: Message | ModelMessage) {
25
25
  case "assistant":
26
26
  if (typeof message.content === "string") {
27
27
  return message.content;
28
+ } else {
29
+ const textParts = message.content.filter((c) => c.type === "text");
30
+ if (!textParts.length) {
31
+ return undefined;
32
+ }
33
+ return textParts.map((c) => c.text).join("");
28
34
  }
29
- return message.content
30
- .filter((c) => c.type === "text")
31
- .map((c) => c.text)
32
- .join("");
33
35
  case "system":
34
36
  return message.content;
35
37
  // we don't extract text from tool messages
@@ -170,7 +170,7 @@ describe("toUIMessages", () => {
170
170
  const uiMessages = toUIMessages(messages);
171
171
  expect(uiMessages).toHaveLength(1);
172
172
  expect(uiMessages[0].role).toBe("assistant");
173
- expect(uiMessages[0].text).toBe("Here's one idea. Here's another idea.");
173
+ expect(uiMessages[0].text).toBe("Here's one idea.Here's another idea.");
174
174
  expect(uiMessages[0].parts.filter((p) => p.type === "reasoning")).toEqual([
175
175
  {
176
176
  providerOptions: undefined,