@convex-dev/agent 0.5.0-alpha.1 → 0.6.0-alpha.0

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 (233) hide show
  1. package/README.md +32 -27
  2. package/dist/UIMessages.d.ts +46 -0
  3. package/dist/UIMessages.d.ts.map +1 -0
  4. package/dist/UIMessages.js +546 -0
  5. package/dist/UIMessages.js.map +1 -0
  6. package/dist/client/createTool.d.ts +129 -27
  7. package/dist/client/createTool.d.ts.map +1 -1
  8. package/dist/client/createTool.js +66 -12
  9. package/dist/client/createTool.js.map +1 -1
  10. package/dist/client/defaultComponent.d.ts +11 -0
  11. package/dist/client/defaultComponent.d.ts.map +1 -0
  12. package/dist/client/defaultComponent.js +7 -0
  13. package/dist/client/defaultComponent.js.map +1 -0
  14. package/dist/client/definePlaygroundAPI.d.ts +1323 -192
  15. package/dist/client/definePlaygroundAPI.d.ts.map +1 -1
  16. package/dist/client/definePlaygroundAPI.js +52 -28
  17. package/dist/client/definePlaygroundAPI.js.map +1 -1
  18. package/dist/client/files.d.ts +20 -7
  19. package/dist/client/files.d.ts.map +1 -1
  20. package/dist/client/files.js +68 -11
  21. package/dist/client/files.js.map +1 -1
  22. package/dist/client/index.d.ts +1056 -965
  23. package/dist/client/index.d.ts.map +1 -1
  24. package/dist/client/index.js +242 -748
  25. package/dist/client/index.js.map +1 -1
  26. package/dist/client/messages.d.ts +461 -0
  27. package/dist/client/messages.d.ts.map +1 -0
  28. package/dist/client/messages.js +106 -0
  29. package/dist/client/messages.js.map +1 -0
  30. package/dist/client/mockModel.d.ts +42 -0
  31. package/dist/client/mockModel.d.ts.map +1 -0
  32. package/dist/client/mockModel.js +175 -0
  33. package/dist/client/mockModel.js.map +1 -0
  34. package/dist/client/saveInputMessages.d.ts +20 -0
  35. package/dist/client/saveInputMessages.d.ts.map +1 -0
  36. package/dist/client/saveInputMessages.js +58 -0
  37. package/dist/client/saveInputMessages.js.map +1 -0
  38. package/dist/client/search.d.ts +346 -35
  39. package/dist/client/search.d.ts.map +1 -1
  40. package/dist/client/search.js +350 -39
  41. package/dist/client/search.js.map +1 -1
  42. package/dist/client/start.d.ts +84 -0
  43. package/dist/client/start.d.ts.map +1 -0
  44. package/dist/client/start.js +171 -0
  45. package/dist/client/start.js.map +1 -0
  46. package/dist/client/streamText.d.ts +46 -0
  47. package/dist/client/streamText.d.ts.map +1 -0
  48. package/dist/client/streamText.js +93 -0
  49. package/dist/client/streamText.js.map +1 -0
  50. package/dist/client/streaming.d.ts +3705 -32
  51. package/dist/client/streaming.d.ts.map +1 -1
  52. package/dist/client/streaming.js +141 -59
  53. package/dist/client/streaming.js.map +1 -1
  54. package/dist/client/threads.d.ts +46 -0
  55. package/dist/client/threads.d.ts.map +1 -0
  56. package/dist/client/threads.js +49 -0
  57. package/dist/client/threads.js.map +1 -0
  58. package/dist/client/types.d.ts +265 -128
  59. package/dist/client/types.d.ts.map +1 -1
  60. package/dist/client/utils.d.ts +4 -0
  61. package/dist/client/utils.d.ts.map +1 -0
  62. package/dist/client/utils.js +21 -0
  63. package/dist/client/utils.js.map +1 -0
  64. package/dist/component/_generated/api.d.ts +24 -2178
  65. package/dist/component/_generated/api.d.ts.map +1 -1
  66. package/dist/component/_generated/api.js +10 -1
  67. package/dist/component/_generated/api.js.map +1 -1
  68. package/dist/component/_generated/component.d.ts +3119 -0
  69. package/dist/component/_generated/component.d.ts.map +1 -0
  70. package/dist/component/_generated/component.js +11 -0
  71. package/dist/component/_generated/component.js.map +1 -0
  72. package/dist/component/_generated/dataModel.d.ts +4 -18
  73. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  74. package/dist/component/_generated/dataModel.js +11 -0
  75. package/dist/component/_generated/dataModel.js.map +1 -0
  76. package/dist/component/_generated/server.d.ts +10 -38
  77. package/dist/component/_generated/server.d.ts.map +1 -1
  78. package/dist/component/_generated/server.js +9 -5
  79. package/dist/component/_generated/server.js.map +1 -1
  80. package/dist/component/files.d.ts +16 -10
  81. package/dist/component/files.d.ts.map +1 -1
  82. package/dist/component/files.js +10 -2
  83. package/dist/component/files.js.map +1 -1
  84. package/dist/component/messages.d.ts +2553 -342
  85. package/dist/component/messages.d.ts.map +1 -1
  86. package/dist/component/messages.js +387 -154
  87. package/dist/component/messages.js.map +1 -1
  88. package/dist/component/schema.d.ts +5697 -3584
  89. package/dist/component/schema.d.ts.map +1 -1
  90. package/dist/component/schema.js +18 -41
  91. package/dist/component/schema.js.map +1 -1
  92. package/dist/component/streams.d.ts +35 -335
  93. package/dist/component/streams.d.ts.map +1 -1
  94. package/dist/component/streams.js +114 -73
  95. package/dist/component/streams.js.map +1 -1
  96. package/dist/component/threads.d.ts +16 -16
  97. package/dist/component/users.d.ts +4 -4
  98. package/dist/component/vector/index.d.ts +1 -1
  99. package/dist/component/vector/index.d.ts.map +1 -1
  100. package/dist/component/vector/index.js +1 -3
  101. package/dist/component/vector/index.js.map +1 -1
  102. package/dist/deltas.d.ts +43 -0
  103. package/dist/deltas.d.ts.map +1 -0
  104. package/dist/deltas.js +447 -0
  105. package/dist/deltas.js.map +1 -0
  106. package/dist/mapping.d.ts +20 -20
  107. package/dist/mapping.d.ts.map +1 -1
  108. package/dist/mapping.js +313 -96
  109. package/dist/mapping.js.map +1 -1
  110. package/dist/react/SmoothText.d.ts +5 -0
  111. package/dist/react/SmoothText.d.ts.map +1 -0
  112. package/dist/react/SmoothText.js +6 -0
  113. package/dist/react/SmoothText.js.map +1 -0
  114. package/dist/react/index.d.ts +5 -77
  115. package/dist/react/index.d.ts.map +1 -1
  116. package/dist/react/index.js +6 -160
  117. package/dist/react/index.js.map +1 -1
  118. package/dist/react/optimisticallySendMessage.d.ts +36 -3
  119. package/dist/react/optimisticallySendMessage.d.ts.map +1 -1
  120. package/dist/react/optimisticallySendMessage.js +35 -9
  121. package/dist/react/optimisticallySendMessage.js.map +1 -1
  122. package/dist/react/types.d.ts +4 -18
  123. package/dist/react/types.d.ts.map +1 -1
  124. package/dist/react/useDeltaStreams.d.ts +10 -0
  125. package/dist/react/useDeltaStreams.d.ts.map +1 -0
  126. package/dist/react/useDeltaStreams.js +101 -0
  127. package/dist/react/useDeltaStreams.js.map +1 -0
  128. package/dist/react/useSmoothText.d.ts +13 -12
  129. package/dist/react/useSmoothText.d.ts.map +1 -1
  130. package/dist/react/useSmoothText.js +32 -15
  131. package/dist/react/useSmoothText.js.map +1 -1
  132. package/dist/react/useStreamingUIMessages.d.ts +22 -0
  133. package/dist/react/useStreamingUIMessages.d.ts.map +1 -0
  134. package/dist/react/useStreamingUIMessages.js +92 -0
  135. package/dist/react/useStreamingUIMessages.js.map +1 -0
  136. package/dist/react/useThreadMessages.d.ts +104 -0
  137. package/dist/react/useThreadMessages.d.ts.map +1 -0
  138. package/dist/react/useThreadMessages.js +148 -0
  139. package/dist/react/useThreadMessages.js.map +1 -0
  140. package/dist/react/useUIMessages.d.ts +96 -0
  141. package/dist/react/useUIMessages.d.ts.map +1 -0
  142. package/dist/react/useUIMessages.js +108 -0
  143. package/dist/react/useUIMessages.js.map +1 -0
  144. package/dist/shared.d.ts +20 -4
  145. package/dist/shared.d.ts.map +1 -1
  146. package/dist/shared.js +45 -8
  147. package/dist/shared.js.map +1 -1
  148. package/dist/validators.d.ts +22981 -5666
  149. package/dist/validators.d.ts.map +1 -1
  150. package/dist/validators.js +245 -137
  151. package/dist/validators.js.map +1 -1
  152. package/package.json +98 -50
  153. package/src/UIMessages.combineUIMessages.test.ts +239 -0
  154. package/src/UIMessages.test.ts +273 -0
  155. package/src/UIMessages.ts +739 -0
  156. package/src/client/createTool.ts +293 -76
  157. package/src/client/defaultComponent.ts +17 -0
  158. package/src/client/definePlaygroundAPI.ts +67 -31
  159. package/src/client/files.ts +100 -20
  160. package/src/client/index.test.ts +40 -85
  161. package/src/client/index.ts +520 -1290
  162. package/src/client/messages.ts +237 -0
  163. package/src/client/mockModel.ts +245 -0
  164. package/src/client/saveInputMessages.test.ts +583 -0
  165. package/src/client/saveInputMessages.ts +101 -0
  166. package/src/client/search.test.ts +1207 -0
  167. package/src/client/search.ts +577 -70
  168. package/src/client/start.ts +310 -0
  169. package/src/client/streamText.ts +163 -0
  170. package/src/client/streaming.test.ts +186 -0
  171. package/src/client/streaming.ts +219 -97
  172. package/src/client/threads.ts +83 -0
  173. package/src/client/types.ts +368 -219
  174. package/src/client/utils.ts +27 -0
  175. package/src/component/_generated/api.ts +64 -0
  176. package/src/component/_generated/component.ts +4913 -0
  177. package/src/component/_generated/{server.d.ts → server.ts} +33 -21
  178. package/src/component/files.ts +11 -2
  179. package/src/component/messages.test.ts +195 -51
  180. package/src/component/messages.ts +490 -201
  181. package/src/component/schema.ts +20 -46
  182. package/src/component/setup.test.ts +7 -0
  183. package/src/component/streams.ts +184 -83
  184. package/src/component/users.test.ts +0 -1
  185. package/src/component/vector/index.ts +1 -3
  186. package/src/deltas.test.ts +626 -0
  187. package/src/deltas.ts +570 -0
  188. package/src/fromUIMessages.test.ts +497 -0
  189. package/src/mapping.test.ts +103 -6
  190. package/src/mapping.ts +422 -161
  191. package/src/react/SmoothText.tsx +9 -0
  192. package/src/react/index.ts +10 -230
  193. package/src/react/optimisticallySendMessage.ts +55 -12
  194. package/src/react/types.ts +6 -39
  195. package/src/react/useDeltaStreams.ts +154 -0
  196. package/src/react/useSmoothText.ts +56 -36
  197. package/src/react/useStreamingUIMessages.ts +143 -0
  198. package/src/react/useThreadMessages.ts +262 -0
  199. package/src/react/useUIMessages.test.ts +255 -0
  200. package/src/react/useUIMessages.ts +195 -0
  201. package/src/shared.ts +88 -12
  202. package/src/test.ts +18 -0
  203. package/src/toUIMessages.test.ts +1269 -0
  204. package/src/validators.test.ts +18 -19
  205. package/src/validators.ts +325 -185
  206. package/dist/client/_generated/_ignore.d.ts +0 -1
  207. package/dist/client/_generated/_ignore.d.ts.map +0 -1
  208. package/dist/client/_generated/_ignore.js +0 -3
  209. package/dist/client/_generated/_ignore.js.map +0 -1
  210. package/dist/client/listMessages.d.ts +0 -22
  211. package/dist/client/listMessages.d.ts.map +0 -1
  212. package/dist/client/listMessages.js +0 -25
  213. package/dist/client/listMessages.js.map +0 -1
  214. package/dist/package.json +0 -3
  215. package/dist/react/deltas.d.ts +0 -26
  216. package/dist/react/deltas.d.ts.map +0 -1
  217. package/dist/react/deltas.js +0 -384
  218. package/dist/react/deltas.js.map +0 -1
  219. package/dist/react/toUIMessages.d.ts +0 -15
  220. package/dist/react/toUIMessages.d.ts.map +0 -1
  221. package/dist/react/toUIMessages.js +0 -211
  222. package/dist/react/toUIMessages.js.map +0 -1
  223. package/src/client/listMessages.ts +0 -38
  224. package/src/component/_generated/api.d.ts +0 -2202
  225. package/src/component/_generated/api.js +0 -23
  226. package/src/component/_generated/server.js +0 -90
  227. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  228. package/src/react/deltas.test.ts +0 -315
  229. package/src/react/deltas.ts +0 -478
  230. package/src/react/toUIMessages.test.ts +0 -420
  231. package/src/react/toUIMessages.ts +0 -253
  232. package/src/vitest.config.ts +0 -7
  233. /package/src/component/_generated/{dataModel.d.ts → dataModel.ts} +0 -0
@@ -0,0 +1,1207 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ vi,
6
+ beforeEach,
7
+ type MockedFunction,
8
+ } from "vitest";
9
+ import type { ModelMessage } from "ai";
10
+ import {
11
+ defineSchema,
12
+ type Auth,
13
+ type StorageActionWriter,
14
+ type StorageReader,
15
+ } from "convex/server";
16
+ import type { MessageDoc } from "../validators.js";
17
+ import type { ActionCtx, QueryCtx } from "./types.js";
18
+ import {
19
+ fetchContextWithPrompt,
20
+ fetchContextMessages,
21
+ filterOutOrphanedToolMessages,
22
+ getPromptArray,
23
+ } from "./search.js";
24
+ import { components, initConvexTest } from "./setup.test.js";
25
+ import { createThread } from "./threads.js";
26
+ import { saveMessages } from "./messages.js";
27
+
28
+ // Helper to create mock MessageDoc
29
+ const createMockMessageDoc = (
30
+ id: string,
31
+ role: "user" | "assistant" | "tool" | "system",
32
+ content: any,
33
+ order: number = 1,
34
+ ): MessageDoc => ({
35
+ _id: id,
36
+ _creationTime: Date.now(),
37
+ userId: "test-user",
38
+ threadId: "test-thread",
39
+ order,
40
+ stepOrder: order,
41
+ status: "success",
42
+ tool: false,
43
+ message: { role, content },
44
+ });
45
+
46
+ const schema = defineSchema({});
47
+
48
+ describe("search.ts", () => {
49
+ let t = initConvexTest(schema);
50
+ let mockCtx: ActionCtx;
51
+ let ctx: ActionCtx;
52
+
53
+ // Shared helper functions
54
+ async function createTestThread(userId: string) {
55
+ return await t.run(async (mutCtx) => {
56
+ return await createThread(mutCtx, components.agent, {
57
+ userId,
58
+ });
59
+ });
60
+ }
61
+
62
+ async function createTestMessages(
63
+ threadId: string,
64
+ userId: string,
65
+ messages: Array<{
66
+ role: "user" | "assistant";
67
+ content: string;
68
+ order: number;
69
+ }>,
70
+ ) {
71
+ await t.run(async (mutCtx) => {
72
+ await saveMessages(mutCtx, components.agent, {
73
+ threadId,
74
+ userId,
75
+ messages: messages.map((msg) => ({
76
+ role: msg.role,
77
+ content: msg.content,
78
+ })),
79
+ metadata: messages.map((msg) => ({
80
+ order: msg.order,
81
+ stepOrder: msg.order,
82
+ status: "success" as const,
83
+ })),
84
+ });
85
+ });
86
+ }
87
+
88
+ beforeEach(() => {
89
+ vi.clearAllMocks();
90
+ t = initConvexTest(schema);
91
+ ctx = {
92
+ runQuery: t.query,
93
+ runAction: t.action,
94
+ runMutation: t.mutation,
95
+ } as ActionCtx;
96
+
97
+ mockCtx = {
98
+ runQuery: vi.fn(),
99
+ runAction: vi.fn(),
100
+ runMutation: vi.fn(),
101
+ auth: {} as Auth,
102
+ storage: {} as StorageActionWriter,
103
+ } satisfies ActionCtx;
104
+
105
+ // Mock process.env to avoid file inlining in tests
106
+ process.env.CONVEX_CLOUD_URL = "https://example.convex.cloud";
107
+ });
108
+
109
+ describe("getPromptArray", () => {
110
+ it("should return empty array for undefined prompt", () => {
111
+ expect(getPromptArray(undefined)).toEqual([]);
112
+ });
113
+
114
+ it("should return array as-is for array prompt", () => {
115
+ const prompt: ModelMessage[] = [
116
+ { role: "user", content: "Hello" },
117
+ { role: "assistant", content: "Hi there!" },
118
+ ];
119
+ expect(getPromptArray(prompt)).toEqual(prompt);
120
+ });
121
+
122
+ it("should convert string prompt to user message", () => {
123
+ const prompt = "Hello world";
124
+ expect(getPromptArray(prompt)).toEqual([
125
+ { role: "user", content: "Hello world" },
126
+ ]);
127
+ });
128
+ });
129
+
130
+ describe("filterOutOrphanedToolMessages", () => {
131
+ it("should keep non-tool messages", () => {
132
+ const messages: MessageDoc[] = [
133
+ {
134
+ _id: "1",
135
+ message: { role: "user", content: "Hello" },
136
+ order: 1,
137
+ } as MessageDoc,
138
+ {
139
+ _id: "2",
140
+ message: { role: "assistant", content: "Hi!" },
141
+ order: 2,
142
+ } as MessageDoc,
143
+ ];
144
+
145
+ const result = filterOutOrphanedToolMessages(messages);
146
+ expect(result).toHaveLength(2);
147
+ expect(result).toEqual(messages);
148
+ });
149
+
150
+ it("should keep tool messages with corresponding tool calls", () => {
151
+ const messages: MessageDoc[] = [
152
+ {
153
+ _id: "1",
154
+ message: {
155
+ role: "assistant",
156
+ content: [
157
+ { type: "text", text: "I'll help you with that" },
158
+ {
159
+ type: "tool-call",
160
+ toolCallId: "call_123",
161
+ toolName: "test",
162
+ input: {},
163
+ args: {},
164
+ },
165
+ ],
166
+ },
167
+ order: 1,
168
+ } as MessageDoc,
169
+ {
170
+ _id: "2",
171
+ message: {
172
+ role: "tool",
173
+ content: [
174
+ {
175
+ type: "tool-result",
176
+ toolCallId: "call_123",
177
+ result: "success",
178
+ },
179
+ ],
180
+ },
181
+ order: 2,
182
+ } as MessageDoc,
183
+ ];
184
+
185
+ const result = filterOutOrphanedToolMessages(messages);
186
+ expect(result).toHaveLength(2);
187
+ expect(result).toEqual(messages);
188
+ });
189
+
190
+ it("should filter out orphaned tool messages", () => {
191
+ const messages: MessageDoc[] = [
192
+ {
193
+ _id: "0",
194
+ message: { role: "user", content: "Hello" },
195
+ order: 1,
196
+ } as MessageDoc,
197
+ {
198
+ _id: "1",
199
+ message: {
200
+ role: "assistant",
201
+ content: [
202
+ {
203
+ type: "tool-call",
204
+ toolCallId: "call_orphaned",
205
+ toolName: "test",
206
+ input: {},
207
+ args: {},
208
+ },
209
+ ],
210
+ },
211
+ order: 1,
212
+ } as MessageDoc,
213
+ {
214
+ _id: "2",
215
+ message: {
216
+ role: "tool",
217
+ content: [
218
+ {
219
+ type: "tool-result",
220
+ toolCallId: "result_orphaned",
221
+ result: "orphaned",
222
+ },
223
+ ],
224
+ },
225
+ order: 2,
226
+ } as MessageDoc,
227
+ {
228
+ _id: "3",
229
+ message: { role: "assistant", content: "I'll help you with that" },
230
+ order: 1,
231
+ } as MessageDoc,
232
+ ];
233
+
234
+ const result = filterOutOrphanedToolMessages(messages);
235
+ expect(result).toHaveLength(2);
236
+ expect(result[0]._id).toBe("0");
237
+ expect(result[1]._id).toBe("3");
238
+ });
239
+
240
+ it("should keep tool calls with approval responses (but no tool-result yet)", () => {
241
+ const messages: MessageDoc[] = [
242
+ {
243
+ _id: "1",
244
+ message: {
245
+ role: "assistant",
246
+ content: [
247
+ { type: "text", text: "I'll run the dangerous tool" },
248
+ {
249
+ type: "tool-call",
250
+ toolCallId: "call_123",
251
+ toolName: "dangerousTool",
252
+ input: { action: "delete" },
253
+ args: { action: "delete" },
254
+ },
255
+ {
256
+ type: "tool-approval-request",
257
+ toolCallId: "call_123",
258
+ approvalId: "approval_456",
259
+ },
260
+ ],
261
+ },
262
+ order: 1,
263
+ } as MessageDoc,
264
+ {
265
+ _id: "2",
266
+ message: {
267
+ role: "tool",
268
+ content: [
269
+ {
270
+ type: "tool-approval-response",
271
+ approvalId: "approval_456",
272
+ approved: true,
273
+ },
274
+ ],
275
+ },
276
+ order: 2,
277
+ } as MessageDoc,
278
+ ];
279
+
280
+ const result = filterOutOrphanedToolMessages(messages);
281
+ expect(result).toHaveLength(2);
282
+ // The assistant message should still contain the tool-call
283
+ expect(result[0]._id).toBe("1");
284
+ const assistantContent = result[0].message?.content;
285
+ expect(Array.isArray(assistantContent)).toBe(true);
286
+ if (Array.isArray(assistantContent)) {
287
+ const toolCall = assistantContent.find((p) => p.type === "tool-call");
288
+ expect(toolCall).toBeDefined();
289
+ expect(toolCall?.toolCallId).toBe("call_123");
290
+ }
291
+ // The tool message with approval response should be kept
292
+ expect(result[1]._id).toBe("2");
293
+ });
294
+
295
+ it("should filter out tool calls with approval request but NO approval response", () => {
296
+ const messages: MessageDoc[] = [
297
+ {
298
+ _id: "1",
299
+ message: {
300
+ role: "assistant",
301
+ content: [
302
+ { type: "text", text: "I'll run the dangerous tool" },
303
+ {
304
+ type: "tool-call",
305
+ toolCallId: "call_123",
306
+ toolName: "dangerousTool",
307
+ input: { action: "delete" },
308
+ args: { action: "delete" },
309
+ },
310
+ {
311
+ type: "tool-approval-request",
312
+ toolCallId: "call_123",
313
+ approvalId: "approval_456",
314
+ },
315
+ ],
316
+ },
317
+ order: 1,
318
+ } as MessageDoc,
319
+ // No approval response provided
320
+ ];
321
+
322
+ const result = filterOutOrphanedToolMessages(messages);
323
+ expect(result).toHaveLength(1);
324
+ // The assistant message should have the tool-call filtered out
325
+ const assistantContent = result[0].message?.content;
326
+ expect(Array.isArray(assistantContent)).toBe(true);
327
+ if (Array.isArray(assistantContent)) {
328
+ // Text and approval-request should remain, but tool-call should be filtered
329
+ expect(assistantContent).toHaveLength(2);
330
+ expect(assistantContent.find((p) => p.type === "text")).toBeDefined();
331
+ expect(
332
+ assistantContent.find((p) => p.type === "tool-approval-request"),
333
+ ).toBeDefined();
334
+ expect(
335
+ assistantContent.find((p) => p.type === "tool-call"),
336
+ ).toBeUndefined();
337
+ }
338
+ });
339
+
340
+ it("should handle mix of tool calls with results and with approvals", () => {
341
+ const messages: MessageDoc[] = [
342
+ {
343
+ _id: "1",
344
+ message: {
345
+ role: "assistant",
346
+ content: [
347
+ {
348
+ type: "tool-call",
349
+ toolCallId: "call_with_result",
350
+ toolName: "safeTool",
351
+ input: {},
352
+ args: {},
353
+ },
354
+ {
355
+ type: "tool-call",
356
+ toolCallId: "call_with_approval",
357
+ toolName: "dangerousTool",
358
+ input: {},
359
+ args: {},
360
+ },
361
+ {
362
+ type: "tool-approval-request",
363
+ toolCallId: "call_with_approval",
364
+ approvalId: "approval_789",
365
+ },
366
+ ],
367
+ },
368
+ order: 1,
369
+ } as MessageDoc,
370
+ {
371
+ _id: "2",
372
+ message: {
373
+ role: "tool",
374
+ content: [
375
+ {
376
+ type: "tool-result",
377
+ toolCallId: "call_with_result",
378
+ result: "success",
379
+ },
380
+ {
381
+ type: "tool-approval-response",
382
+ approvalId: "approval_789",
383
+ approved: true,
384
+ },
385
+ ],
386
+ },
387
+ order: 2,
388
+ } as MessageDoc,
389
+ ];
390
+
391
+ const result = filterOutOrphanedToolMessages(messages);
392
+ expect(result).toHaveLength(2);
393
+ // Both tool calls should be kept
394
+ const assistantContent = result[0].message?.content;
395
+ expect(Array.isArray(assistantContent)).toBe(true);
396
+ if (Array.isArray(assistantContent)) {
397
+ const toolCalls = assistantContent.filter(
398
+ (p) => p.type === "tool-call",
399
+ );
400
+ expect(toolCalls).toHaveLength(2);
401
+ }
402
+ });
403
+ });
404
+
405
+ describe("fetchContextMessages", () => {
406
+ it("should throw error if neither userId nor threadId provided", async () => {
407
+ await expect(
408
+ fetchContextMessages(mockCtx, components.agent, {
409
+ userId: undefined,
410
+ threadId: undefined,
411
+ contextOptions: {},
412
+ }),
413
+ ).rejects.toThrow("Specify userId or threadId");
414
+ });
415
+
416
+ it("should fetch recent messages when threadId provided", async () => {
417
+ const mockPage = [
418
+ createMockMessageDoc("2", "assistant", "Hi!", 2),
419
+ createMockMessageDoc("1", "user", "Hello", 1),
420
+ ];
421
+
422
+ (
423
+ mockCtx.runQuery as MockedFunction<ActionCtx["runQuery"]>
424
+ ).mockResolvedValue({
425
+ page: mockPage,
426
+ });
427
+
428
+ const result = await fetchContextMessages(mockCtx, components.agent, {
429
+ userId: undefined,
430
+ threadId: "thread123",
431
+ contextOptions: { recentMessages: 10 },
432
+ });
433
+
434
+ expect(mockCtx.runQuery).toHaveBeenCalledWith(expect.anything(), {
435
+ threadId: "thread123",
436
+ paginationOpts: { numItems: 10, cursor: null },
437
+ order: "desc",
438
+ excludeToolMessages: undefined,
439
+ statuses: ["success"],
440
+ upToAndIncludingMessageId: undefined,
441
+ });
442
+
443
+ expect(result.length).toBe(2);
444
+ expect(result[0]._id).toBe("1"); // Should be reversed back to asc order
445
+ expect(result[1]._id).toBe("2");
446
+ });
447
+
448
+ it("should skip recent messages when recentMessages is 0", async () => {
449
+ const result = await fetchContextMessages(mockCtx, components.agent, {
450
+ userId: "user123",
451
+ threadId: "thread123",
452
+ contextOptions: { recentMessages: 0 },
453
+ });
454
+
455
+ expect(mockCtx.runQuery).not.toHaveBeenCalled();
456
+ expect(result).toEqual([]);
457
+ });
458
+
459
+ it("should perform search when searchOptions provided", async () => {
460
+ const searchResults = [
461
+ createMockMessageDoc("search1", "user", "Search result", 0),
462
+ ];
463
+
464
+ (
465
+ mockCtx.runAction as MockedFunction<ActionCtx["runAction"]>
466
+ ).mockResolvedValue(searchResults);
467
+
468
+ const result = await fetchContextMessages(mockCtx, components.agent, {
469
+ userId: "user123",
470
+ threadId: "thread123",
471
+ searchText: "test query",
472
+ contextOptions: {
473
+ recentMessages: 0,
474
+ searchOptions: {
475
+ textSearch: true,
476
+ limit: 5,
477
+ },
478
+ },
479
+ });
480
+
481
+ expect(result.length).toBe(1);
482
+ expect(result[0]._id).toBe("search1");
483
+ });
484
+
485
+ it("should throw error when trying to search in non-action context", async () => {
486
+ const mockQueryCtx = {
487
+ runQuery: vi.fn().mockResolvedValue({ page: [] }),
488
+ // No runAction method
489
+ storage: {} as StorageReader,
490
+ } as QueryCtx;
491
+
492
+ await expect(
493
+ fetchContextMessages(mockQueryCtx, components.agent, {
494
+ userId: "user123",
495
+ threadId: "thread123",
496
+ contextOptions: {
497
+ searchOptions: {
498
+ textSearch: true,
499
+ limit: 5,
500
+ },
501
+ },
502
+ }),
503
+ ).rejects.toThrow("searchUserMessages only works in an action");
504
+ });
505
+ });
506
+
507
+ describe("fetchContextWithPrompt", () => {
508
+ const baseArgs = {
509
+ userId: "user123",
510
+ threadId: "thread123",
511
+ agentName: "test-agent",
512
+ contextOptions: {},
513
+ usageHandler: undefined,
514
+ callSettings: {},
515
+ };
516
+
517
+ beforeEach(() => {
518
+ // Mock fetchContextMessages to return empty array by default
519
+ vi.mocked(mockCtx.runQuery).mockResolvedValue({ page: [] });
520
+ vi.mocked(mockCtx.runAction).mockResolvedValue([]);
521
+ });
522
+
523
+ it("should handle string prompt correctly", async () => {
524
+ const result = await fetchContextWithPrompt(mockCtx, components.agent, {
525
+ ...baseArgs,
526
+ prompt: "Hello, how are you?",
527
+ messages: undefined,
528
+ promptMessageId: undefined,
529
+ });
530
+
531
+ expect(result.messages).toHaveLength(1);
532
+ expect(result.messages[0]).toEqual({
533
+ role: "user",
534
+ content: "Hello, how are you?",
535
+ });
536
+ expect(result.order).toBeUndefined();
537
+ expect(result.stepOrder).toBeUndefined();
538
+ });
539
+
540
+ it("should handle array prompt correctly", async () => {
541
+ const promptMessages: ModelMessage[] = [
542
+ { role: "user", content: "Hello" },
543
+ { role: "assistant", content: "Hi there!" },
544
+ { role: "user", content: "How are you?" },
545
+ ];
546
+
547
+ const result = await fetchContextWithPrompt(mockCtx, components.agent, {
548
+ ...baseArgs,
549
+ prompt: promptMessages,
550
+ messages: undefined,
551
+ promptMessageId: undefined,
552
+ });
553
+
554
+ expect(result.messages).toHaveLength(3);
555
+ expect(result.messages).toEqual(promptMessages);
556
+ });
557
+
558
+ it("should combine context messages with prompt", async () => {
559
+ const contextMessages: MessageDoc[] = [
560
+ {
561
+ _id: "ctx1",
562
+ message: { role: "user", content: "Context message 1" },
563
+ order: 1,
564
+ } as MessageDoc,
565
+ {
566
+ _id: "ctx2",
567
+ message: { role: "assistant", content: "Context response 1" },
568
+ order: 2,
569
+ } as MessageDoc,
570
+ ];
571
+
572
+ // Mock the internal fetchContextMessages call
573
+ vi.mocked(mockCtx.runQuery).mockResolvedValue({
574
+ page: [...contextMessages].reverse(),
575
+ });
576
+
577
+ const result = await fetchContextWithPrompt(mockCtx, components.agent, {
578
+ ...baseArgs,
579
+ prompt: "New prompt",
580
+ messages: undefined,
581
+ promptMessageId: undefined,
582
+ contextOptions: { recentMessages: 10 },
583
+ });
584
+
585
+ expect(result.messages).toHaveLength(3);
586
+ expect(result.messages[0].content).toBe("Context message 1");
587
+ expect(result.messages[1].content).toBe("Context response 1");
588
+ expect(result.messages[2]).toEqual({
589
+ role: "user",
590
+ content: "New prompt",
591
+ });
592
+ });
593
+
594
+ it("should handle input messages correctly", async () => {
595
+ const inputMessages: ModelMessage[] = [
596
+ { role: "user", content: "Input message 1" },
597
+ { role: "assistant", content: "Input response 1" },
598
+ ];
599
+
600
+ const result = await fetchContextWithPrompt(mockCtx, components.agent, {
601
+ ...baseArgs,
602
+ prompt: "Final prompt",
603
+ messages: inputMessages,
604
+ promptMessageId: undefined,
605
+ });
606
+
607
+ expect(result.messages).toHaveLength(3);
608
+ expect(result.messages[0]).toEqual(inputMessages[0]);
609
+ expect(result.messages[1]).toEqual(inputMessages[1]);
610
+ expect(result.messages[2]).toEqual({
611
+ role: "user",
612
+ content: "Final prompt",
613
+ });
614
+ });
615
+
616
+ it("should splice prompt messages when promptMessageId provided", async () => {
617
+ const contextMessages: MessageDoc[] = [
618
+ {
619
+ _id: "msg1",
620
+ message: { role: "user", content: "Before prompt" },
621
+ order: 1,
622
+ } as MessageDoc,
623
+ {
624
+ _id: "prompt-msg",
625
+ message: { role: "user", content: "Original prompt" },
626
+ order: 2,
627
+ } as MessageDoc,
628
+ {
629
+ _id: "msg3",
630
+ message: { role: "assistant", content: "After prompt" },
631
+ order: 3,
632
+ } as MessageDoc,
633
+ ];
634
+
635
+ vi.mocked(mockCtx.runQuery).mockResolvedValue({
636
+ page: [...contextMessages].reverse(),
637
+ });
638
+
639
+ const result = await fetchContextWithPrompt(mockCtx, components.agent, {
640
+ ...baseArgs,
641
+ prompt: "New replacement prompt",
642
+ messages: undefined,
643
+ promptMessageId: "prompt-msg",
644
+ contextOptions: { recentMessages: 10 },
645
+ });
646
+
647
+ expect(result.messages).toHaveLength(3);
648
+ expect(result.messages[0].content).toBe("Before prompt");
649
+ expect(result.messages[1]).toEqual({
650
+ role: "user",
651
+ content: "New replacement prompt",
652
+ });
653
+ expect(result.messages[2].content).toBe("After prompt");
654
+ expect(result.order).toBe(2);
655
+ });
656
+
657
+ it("should use original prompt message when no new prompt provided", async () => {
658
+ const contextMessages: MessageDoc[] = [
659
+ {
660
+ _id: "msg1",
661
+ message: { role: "user", content: "Before prompt" },
662
+ order: 1,
663
+ } as MessageDoc,
664
+ {
665
+ _id: "prompt-msg",
666
+ message: { role: "user", content: "Original prompt" },
667
+ order: 2,
668
+ } as MessageDoc,
669
+ {
670
+ _id: "msg3",
671
+ message: { role: "assistant", content: "After prompt" },
672
+ order: 3,
673
+ } as MessageDoc,
674
+ ];
675
+
676
+ vi.mocked(mockCtx.runQuery).mockResolvedValue({
677
+ page: [...contextMessages].reverse(),
678
+ });
679
+
680
+ const result = await fetchContextWithPrompt(mockCtx, components.agent, {
681
+ ...baseArgs,
682
+ prompt: undefined,
683
+ messages: undefined,
684
+ promptMessageId: "prompt-msg",
685
+ contextOptions: { recentMessages: 10 },
686
+ });
687
+
688
+ expect(result.messages).toHaveLength(3);
689
+ expect(result.messages[0].content).toBe("Before prompt");
690
+ expect(result.messages[1].content).toBe("Original prompt");
691
+ expect(result.messages[2].content).toBe("After prompt");
692
+ });
693
+
694
+ it("should handle complex message ordering correctly", async () => {
695
+ const contextMessages: MessageDoc[] = [
696
+ {
697
+ _id: "ctx1",
698
+ message: { role: "user", content: "Context 1" },
699
+ order: 1,
700
+ } as MessageDoc,
701
+ {
702
+ _id: "prompt-msg",
703
+ message: { role: "user", content: "Prompt" },
704
+ order: 3,
705
+ } as MessageDoc,
706
+ {
707
+ _id: "ctx2",
708
+ message: { role: "assistant", content: "Context 2" },
709
+ order: 5,
710
+ } as MessageDoc,
711
+ ];
712
+
713
+ vi.mocked(mockCtx.runQuery).mockResolvedValue({
714
+ page: [...contextMessages].reverse(),
715
+ });
716
+
717
+ const inputMessages: ModelMessage[] = [
718
+ { role: "user", content: "Input message" },
719
+ ];
720
+
721
+ const result = await fetchContextWithPrompt(mockCtx, components.agent, {
722
+ ...baseArgs,
723
+ prompt: "New prompt",
724
+ messages: inputMessages,
725
+ promptMessageId: "prompt-msg",
726
+ contextOptions: { recentMessages: 10 },
727
+ });
728
+
729
+ expect(result.messages).toHaveLength(4);
730
+ expect(result.messages[0].content).toBe("Context 1"); // Pre-prompt
731
+ expect(result.messages[1].content).toBe("Input message"); // Input messages
732
+ expect(result.messages[2].content).toBe("New prompt"); // New prompt
733
+ expect(result.messages[3].content).toBe("Context 2"); // Post-prompt
734
+ });
735
+
736
+ it("should handle empty context and messages", async () => {
737
+ const result = await fetchContextWithPrompt(mockCtx, components.agent, {
738
+ ...baseArgs,
739
+ prompt: undefined,
740
+ messages: undefined,
741
+ promptMessageId: undefined,
742
+ });
743
+
744
+ expect(result.messages).toHaveLength(0);
745
+ expect(result.order).toBeUndefined();
746
+ expect(result.stepOrder).toBeUndefined();
747
+ });
748
+ });
749
+
750
+ describe("fetchContextWithPrompt - Integration Tests", () => {
751
+ const baseArgs = {
752
+ userId: "user123",
753
+ threadId: "thread123",
754
+ agentName: "test-agent",
755
+ contextOptions: {},
756
+ usageHandler: undefined,
757
+ callSettings: {},
758
+ };
759
+
760
+ it("should fetch and combine real messages with prompt", async () => {
761
+ const threadId = await createTestThread("user123");
762
+
763
+ await createTestMessages(threadId, "user123", [
764
+ { role: "user", content: "Hello", order: 1 },
765
+ { role: "assistant", content: "Hi there!", order: 2 },
766
+ { role: "user", content: "How are you?", order: 3 },
767
+ ]);
768
+
769
+ const result = await fetchContextWithPrompt(ctx, components.agent, {
770
+ ...baseArgs,
771
+ threadId,
772
+ prompt: "What's the weather?",
773
+ messages: undefined,
774
+ promptMessageId: undefined,
775
+ contextOptions: { recentMessages: 10 },
776
+ });
777
+
778
+ expect(result.messages).toHaveLength(4);
779
+ expect(result.messages[0].content).toBe("Hello");
780
+ expect(result.messages[1].content).toBe("Hi there!");
781
+ expect(result.messages[2].content).toBe("How are you?");
782
+ expect(result.messages[3]).toEqual({
783
+ role: "user",
784
+ content: "What's the weather?",
785
+ });
786
+ });
787
+
788
+ it("should handle prompt message replacement in real data", async () => {
789
+ const threadId = await createTestThread("user456");
790
+
791
+ // Create messages and capture the prompt message ID
792
+ const messages = [
793
+ { role: "user" as const, content: "Before prompt" },
794
+ { role: "user" as const, content: "Original prompt" },
795
+ { role: "assistant" as const, content: "Assistant response" },
796
+ ];
797
+
798
+ const { messages: savedMessages } = await t.run(async (mutCtx) => {
799
+ return await saveMessages(mutCtx, components.agent, {
800
+ threadId,
801
+ userId: "user456",
802
+ messages: messages.map((msg) => ({
803
+ role: msg.role,
804
+ content: msg.content,
805
+ })),
806
+ metadata: messages.map(() => ({})),
807
+ });
808
+ });
809
+
810
+ const promptMessageId = savedMessages[1]._id; // The prompt message
811
+
812
+ const result = await fetchContextWithPrompt(ctx, components.agent, {
813
+ ...baseArgs,
814
+ userId: "user456",
815
+ threadId,
816
+ prompt: "New replacement prompt",
817
+ messages: undefined,
818
+ promptMessageId,
819
+ contextOptions: { recentMessages: 10 },
820
+ });
821
+
822
+ expect(result.messages).toHaveLength(3);
823
+ expect(result.messages[0].content).toBe("Before prompt");
824
+ expect(result.messages[1]).toEqual({
825
+ role: "user",
826
+ content: "New replacement prompt",
827
+ });
828
+ expect(result.messages[2].content).toBe("Assistant response");
829
+ // The prompt is the second user message, each on a new order.
830
+ expect(result.order).toBe(1);
831
+ expect(result.stepOrder).toBe(0);
832
+ });
833
+
834
+ it("should combine input messages with context and prompt", async () => {
835
+ const threadId = await createTestThread("user789");
836
+
837
+ await createTestMessages(threadId, "user789", [
838
+ { role: "user", content: "Context message", order: 1 },
839
+ { role: "assistant", content: "Context response", order: 2 },
840
+ ]);
841
+
842
+ const inputMessages: ModelMessage[] = [
843
+ { role: "user", content: "Input message 1" },
844
+ { role: "user", content: "Input message 2" },
845
+ ];
846
+
847
+ const result = await fetchContextWithPrompt(ctx, components.agent, {
848
+ ...baseArgs,
849
+ userId: "user789",
850
+ threadId,
851
+ prompt: "Final prompt",
852
+ messages: inputMessages,
853
+ promptMessageId: undefined,
854
+ contextOptions: { recentMessages: 10 },
855
+ });
856
+
857
+ expect(result.messages).toHaveLength(5);
858
+ expect(result.messages[0].content).toBe("Context message");
859
+ expect(result.messages[1].content).toBe("Context response");
860
+ expect(result.messages[2].content).toBe("Input message 1");
861
+ expect(result.messages[3].content).toBe("Input message 2");
862
+ expect(result.messages[4]).toEqual({
863
+ role: "user",
864
+ content: "Final prompt",
865
+ });
866
+ });
867
+
868
+ it("should respect recentMessages limit", async () => {
869
+ const threadId = await createTestThread("user999");
870
+
871
+ // Create 5 messages but only fetch the most recent 2
872
+ await createTestMessages(threadId, "user999", [
873
+ { role: "user", content: "Message 1", order: 1 },
874
+ { role: "assistant", content: "Response 1", order: 2 },
875
+ { role: "user", content: "Message 2", order: 3 },
876
+ { role: "assistant", content: "Response 2", order: 4 },
877
+ { role: "user", content: "Message 3", order: 5 },
878
+ ]);
879
+
880
+ const result = await fetchContextWithPrompt(ctx, components.agent, {
881
+ ...baseArgs,
882
+ userId: "user999",
883
+ threadId,
884
+ prompt: "New prompt",
885
+ messages: undefined,
886
+ promptMessageId: undefined,
887
+ contextOptions: { recentMessages: 2 }, // Only fetch 2 most recent
888
+ });
889
+
890
+ expect(result.messages).toHaveLength(3); // 2 context + 1 prompt
891
+ expect(result.messages[0].content).toBe("Response 2"); // 4th message
892
+ expect(result.messages[1].content).toBe("Message 3"); // 5th message
893
+ expect(result.messages[2]).toEqual({
894
+ role: "user",
895
+ content: "New prompt",
896
+ });
897
+ });
898
+
899
+ it("should handle empty thread gracefully", async () => {
900
+ const threadId = await createTestThread("user000");
901
+
902
+ // Don't create any messages
903
+
904
+ const result = await fetchContextWithPrompt(ctx, components.agent, {
905
+ ...baseArgs,
906
+ userId: "user000",
907
+ threadId,
908
+ prompt: "Only prompt",
909
+ messages: undefined,
910
+ promptMessageId: undefined,
911
+ contextOptions: { recentMessages: 10 },
912
+ });
913
+
914
+ expect(result.messages).toHaveLength(1);
915
+ expect(result.messages[0]).toEqual({
916
+ role: "user",
917
+ content: "Only prompt",
918
+ });
919
+ });
920
+ });
921
+
922
+ describe("fetchContextWithPrompt - contextHandler Tests", () => {
923
+ const baseArgs = {
924
+ userId: "user123",
925
+ threadId: "thread123",
926
+ agentName: "test-agent",
927
+ contextOptions: {},
928
+ usageHandler: undefined,
929
+ callSettings: {},
930
+ };
931
+
932
+ it("should use custom contextHandler to reorder messages", async () => {
933
+ const threadId = await createTestThread("userContext");
934
+
935
+ await createTestMessages(threadId, "userContext", [
936
+ { role: "user", content: "Recent message 1", order: 1 },
937
+ { role: "assistant", content: "Recent response 1", order: 2 },
938
+ ]);
939
+
940
+ // Create a contextHandler that puts inputMessages first, then inputPrompt, then recent
941
+ const contextHandler = vi.fn(async (ctx, args) => {
942
+ return [
943
+ ...args.inputMessages,
944
+ ...args.inputPrompt,
945
+ ...args.recent,
946
+ ...args.search,
947
+ ...args.existingResponses,
948
+ ];
949
+ });
950
+
951
+ const result = await fetchContextWithPrompt(ctx, components.agent, {
952
+ ...baseArgs,
953
+ userId: "userContext",
954
+ threadId,
955
+ prompt: "Custom prompt",
956
+ messages: [{ role: "user", content: "Input message" }],
957
+ promptMessageId: undefined,
958
+ contextOptions: { recentMessages: 10 },
959
+ contextHandler,
960
+ });
961
+
962
+ // Verify contextHandler was called with correct arguments
963
+ expect(contextHandler).toHaveBeenCalledWith(
964
+ ctx,
965
+ expect.objectContaining({
966
+ search: [], // No search performed in this test
967
+ recent: expect.arrayContaining([
968
+ expect.objectContaining({ content: "Recent message 1" }),
969
+ expect.objectContaining({ content: "Recent response 1" }),
970
+ ]),
971
+ inputMessages: expect.arrayContaining([
972
+ expect.objectContaining({ content: "Input message" }),
973
+ ]),
974
+ inputPrompt: expect.arrayContaining([
975
+ expect.objectContaining({ content: "Custom prompt" }),
976
+ ]),
977
+ existingResponses: [], // No existing responses in this test
978
+ userId: "userContext",
979
+ threadId,
980
+ }),
981
+ );
982
+
983
+ // Result should follow the custom order: inputMessages, inputPrompt, recent
984
+ expect(result.messages).toHaveLength(4);
985
+ expect(result.messages[0].content).toBe("Input message"); // inputMessages
986
+ expect(result.messages[1].content).toBe("Custom prompt"); // inputPrompt
987
+ expect(result.messages[2].content).toBe("Recent message 1"); // recent
988
+ expect(result.messages[3].content).toBe("Recent response 1"); // recent
989
+ });
990
+
991
+ it("should allow contextHandler to filter out messages", async () => {
992
+ const threadId = await createTestThread("userFilter");
993
+
994
+ await createTestMessages(threadId, "userFilter", [
995
+ { role: "user", content: "Keep this message", order: 1 },
996
+ { role: "assistant", content: "Filter this out", order: 2 },
997
+ { role: "user", content: "Keep this too", order: 3 },
998
+ ]);
999
+
1000
+ // Create a contextHandler that filters out assistant messages
1001
+ const contextHandler = vi.fn(async (ctx, args) => {
1002
+ const allMessages = [
1003
+ ...args.search,
1004
+ ...args.recent,
1005
+ ...args.inputMessages,
1006
+ ...args.inputPrompt,
1007
+ ...args.existingResponses,
1008
+ ];
1009
+
1010
+ // Filter out assistant messages
1011
+ return allMessages.filter((msg) => msg.role !== "assistant");
1012
+ });
1013
+
1014
+ const result = await fetchContextWithPrompt(ctx, components.agent, {
1015
+ ...baseArgs,
1016
+ userId: "userFilter",
1017
+ threadId,
1018
+ prompt: "Filter prompt",
1019
+ messages: undefined,
1020
+ promptMessageId: undefined,
1021
+ contextOptions: { recentMessages: 10 },
1022
+ contextHandler,
1023
+ });
1024
+
1025
+ // Should only have user messages and the prompt
1026
+ expect(result.messages).toHaveLength(3);
1027
+ expect(result.messages[0].content).toBe("Keep this message");
1028
+ expect(result.messages[1].content).toBe("Keep this too");
1029
+ expect(result.messages[2].content).toBe("Filter prompt");
1030
+
1031
+ // Should not contain the filtered assistant message
1032
+ expect(
1033
+ result.messages.find((m) => m.content === "Filter this out"),
1034
+ ).toBeUndefined();
1035
+ });
1036
+
1037
+ it("should allow contextHandler to add custom messages", async () => {
1038
+ const threadId = await createTestThread("userCustom");
1039
+
1040
+ await createTestMessages(threadId, "userCustom", [
1041
+ { role: "user", content: "Original message", order: 1 },
1042
+ ]);
1043
+
1044
+ // Create a contextHandler that adds a custom system message
1045
+ const contextHandler = vi.fn(async (ctx, args) => {
1046
+ const customSystemMessage = {
1047
+ role: "system" as const,
1048
+ content: "This is a custom system message added by contextHandler",
1049
+ };
1050
+
1051
+ return [customSystemMessage, ...args.recent, ...args.inputPrompt];
1052
+ });
1053
+
1054
+ const result = await fetchContextWithPrompt(ctx, components.agent, {
1055
+ ...baseArgs,
1056
+ userId: "userCustom",
1057
+ threadId,
1058
+ prompt: "Test prompt",
1059
+ messages: undefined,
1060
+ promptMessageId: undefined,
1061
+ contextOptions: { recentMessages: 10 },
1062
+ contextHandler,
1063
+ });
1064
+
1065
+ expect(result.messages).toHaveLength(3);
1066
+ expect(result.messages[0].role).toBe("system");
1067
+ expect(result.messages[0].content).toBe(
1068
+ "This is a custom system message added by contextHandler",
1069
+ );
1070
+ expect(result.messages[1].content).toBe("Original message");
1071
+ expect(result.messages[2].content).toBe("Test prompt");
1072
+ });
1073
+
1074
+ it("should work with search messages in contextHandler", async () => {
1075
+ const threadId = await createTestThread("userSearch");
1076
+
1077
+ // Create multiple messages for search to find
1078
+ await createTestMessages(threadId, "userSearch", [
1079
+ { role: "user", content: "Searchable content about cats", order: 1 },
1080
+ { role: "assistant", content: "Response about cats", order: 2 },
1081
+ { role: "user", content: "Recent non-searchable message", order: 3 },
1082
+ ]);
1083
+
1084
+ const contextHandler = vi.fn(async (ctx, args) => {
1085
+ // Put search messages first, then recent, then prompt
1086
+ return [...args.search, ...args.recent, ...args.inputPrompt];
1087
+ });
1088
+
1089
+ const result = await fetchContextWithPrompt(ctx, components.agent, {
1090
+ ...baseArgs,
1091
+ userId: "userSearch",
1092
+ threadId,
1093
+ prompt: "Tell me about cats",
1094
+ messages: undefined,
1095
+ promptMessageId: undefined,
1096
+ contextOptions: {
1097
+ recentMessages: 1, // Only get 1 recent message
1098
+ searchOptions: {
1099
+ textSearch: true,
1100
+ limit: 2,
1101
+ },
1102
+ },
1103
+ contextHandler,
1104
+ });
1105
+
1106
+ expect(contextHandler).toHaveBeenCalledWith(
1107
+ ctx,
1108
+ expect.objectContaining({
1109
+ search: expect.any(Array),
1110
+ recent: expect.any(Array),
1111
+ inputPrompt: expect.arrayContaining([
1112
+ expect.objectContaining({ content: "Tell me about cats" }),
1113
+ ]),
1114
+ }),
1115
+ );
1116
+
1117
+ // Should have recent + prompt (search may not return results in test environment)
1118
+ expect(result.messages.length).toBeGreaterThanOrEqual(2);
1119
+ expect(result.messages[result.messages.length - 1].content).toBe(
1120
+ "Tell me about cats",
1121
+ );
1122
+ });
1123
+
1124
+ it("should handle existingResponses in contextHandler when promptMessageId provided", async () => {
1125
+ const threadId = await createTestThread("userResponses");
1126
+
1127
+ const { messages: savedMessages } = await t.run(async (mutCtx) => {
1128
+ return await saveMessages(mutCtx, components.agent, {
1129
+ threadId,
1130
+ userId: "userResponses",
1131
+ messages: [
1132
+ { role: "user", content: "Before prompt" },
1133
+ { role: "user", content: "Original prompt" },
1134
+ { role: "assistant", content: "Existing response 1" },
1135
+ { role: "assistant", content: "Existing response 2" },
1136
+ ],
1137
+ metadata: [{}, {}, {}, {}],
1138
+ });
1139
+ });
1140
+
1141
+ const promptMessageId = savedMessages[1]._id; // The prompt message
1142
+
1143
+ const contextHandler = vi.fn(async (ctx, args) => {
1144
+ // Put existing responses first to test they're properly identified
1145
+ return [...args.recent, ...args.existingResponses, ...args.inputPrompt];
1146
+ });
1147
+
1148
+ const result = await fetchContextWithPrompt(ctx, components.agent, {
1149
+ ...baseArgs,
1150
+ userId: "userResponses",
1151
+ threadId,
1152
+ prompt: "New replacement prompt",
1153
+ messages: undefined,
1154
+ promptMessageId,
1155
+ contextOptions: { recentMessages: 10 },
1156
+ contextHandler,
1157
+ });
1158
+
1159
+ expect(contextHandler).toHaveBeenCalledWith(
1160
+ ctx,
1161
+ expect.objectContaining({
1162
+ recent: expect.arrayContaining([
1163
+ expect.objectContaining({ content: "Before prompt" }),
1164
+ ]),
1165
+ existingResponses: expect.arrayContaining([
1166
+ expect.objectContaining({ content: "Existing response 1" }),
1167
+ expect.objectContaining({ content: "Existing response 2" }),
1168
+ ]),
1169
+ inputPrompt: expect.arrayContaining([
1170
+ expect.objectContaining({ content: "New replacement prompt" }),
1171
+ ]),
1172
+ }),
1173
+ );
1174
+
1175
+ expect(result.messages).toHaveLength(4);
1176
+ expect(result.messages[0].content).toBe("Before prompt");
1177
+ expect(result.messages[1].content).toBe("Existing response 1");
1178
+ expect(result.messages[2].content).toBe("Existing response 2");
1179
+ expect(result.messages[3].content).toBe("New replacement prompt");
1180
+ });
1181
+
1182
+ it("should work without contextHandler (default behavior)", async () => {
1183
+ const threadId = await createTestThread("userDefault");
1184
+
1185
+ await createTestMessages(threadId, "userDefault", [
1186
+ { role: "user", content: "Default order test", order: 1 },
1187
+ ]);
1188
+
1189
+ const result = await fetchContextWithPrompt(ctx, components.agent, {
1190
+ ...baseArgs,
1191
+ userId: "userDefault",
1192
+ threadId,
1193
+ prompt: "Default prompt",
1194
+ messages: [{ role: "user", content: "Input message" }],
1195
+ promptMessageId: undefined,
1196
+ contextOptions: { recentMessages: 10 },
1197
+ // No contextHandler provided
1198
+ });
1199
+
1200
+ // Should follow default order: recent, input, prompt
1201
+ expect(result.messages).toHaveLength(3);
1202
+ expect(result.messages[0].content).toBe("Default order test"); // recent
1203
+ expect(result.messages[1].content).toBe("Input message"); // inputMessages
1204
+ expect(result.messages[2].content).toBe("Default prompt"); // inputPrompt
1205
+ });
1206
+ });
1207
+ });