@convex-dev/agent 0.2.8-alpha.2 → 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.
@@ -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,