@convex-dev/agent 0.2.6-alpha.0 → 0.2.6

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