@convex-dev/agent 0.7.1 → 0.7.2

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 (81) hide show
  1. package/README.md +5 -8
  2. package/dist/client/messages.d.ts +10 -0
  3. package/dist/client/messages.d.ts.map +1 -1
  4. package/dist/client/messages.js +1 -0
  5. package/dist/client/messages.js.map +1 -1
  6. package/dist/component/_generated/component.d.ts +10 -8
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/messages.d.ts +340 -338
  9. package/dist/component/messages.d.ts.map +1 -1
  10. package/dist/component/messages.js +74 -14
  11. package/dist/component/messages.js.map +1 -1
  12. package/dist/component/schema.d.ts +726 -622
  13. package/dist/component/schema.d.ts.map +1 -1
  14. package/dist/component/streams.d.ts +6 -1
  15. package/dist/component/streams.d.ts.map +1 -1
  16. package/dist/component/streams.js +14 -13
  17. package/dist/component/streams.js.map +1 -1
  18. package/dist/component/vector/index.d.ts +9 -9
  19. package/dist/component/vector/index.d.ts.map +1 -1
  20. package/dist/component/vector/tables.d.ts +5 -5
  21. package/dist/component/vector/tables.d.ts.map +1 -1
  22. package/dist/component/vector/tables.js +1 -1
  23. package/dist/component/vector/tables.js.map +1 -1
  24. package/dist/errors.d.ts +2 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/errors.js +106 -0
  27. package/dist/errors.js.map +1 -0
  28. package/dist/validators.d.ts +1952 -1952
  29. package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
  30. package/dist/vercel/client/messages.d.ts +50 -32
  31. package/dist/vercel/client/messages.d.ts.map +1 -1
  32. package/dist/vercel/client/messages.js +2 -0
  33. package/dist/vercel/client/messages.js.map +1 -1
  34. package/dist/vercel/client/search.d.ts +475 -32
  35. package/dist/vercel/client/search.d.ts.map +1 -1
  36. package/dist/vercel/client/search.js +34 -9
  37. package/dist/vercel/client/search.js.map +1 -1
  38. package/dist/vercel/client/start.d.ts.map +1 -1
  39. package/dist/vercel/client/start.js +1 -0
  40. package/dist/vercel/client/start.js.map +1 -1
  41. package/dist/vercel/client/streamText.d.ts +1 -2
  42. package/dist/vercel/client/streamText.d.ts.map +1 -1
  43. package/dist/vercel/client/streamText.js +47 -17
  44. package/dist/vercel/client/streamText.js.map +1 -1
  45. package/dist/vercel/client/streaming.d.ts +304 -301
  46. package/dist/vercel/client/streaming.d.ts.map +1 -1
  47. package/dist/vercel/client/streaming.js +22 -9
  48. package/dist/vercel/client/streaming.js.map +1 -1
  49. package/dist/vercel/client/types.d.ts +2 -0
  50. package/dist/vercel/client/types.d.ts.map +1 -1
  51. package/dist/vercel/client/types.js.map +1 -1
  52. package/dist/vercel/client/utils.d.ts +9 -5
  53. package/dist/vercel/client/utils.d.ts.map +1 -1
  54. package/dist/vercel/client/utils.js +13 -13
  55. package/dist/vercel/client/utils.js.map +1 -1
  56. package/dist/vercel/index.d.ts +91 -90
  57. package/dist/vercel/index.d.ts.map +1 -1
  58. package/dist/vercel/index.js +2 -0
  59. package/dist/vercel/index.js.map +1 -1
  60. package/package.json +16 -9
  61. package/src/client/messages.ts +12 -0
  62. package/src/component/_generated/component.ts +10 -0
  63. package/src/component/messages.test.ts +404 -0
  64. package/src/component/messages.ts +94 -12
  65. package/src/component/streams.ts +20 -15
  66. package/src/component/vector/tables.ts +1 -1
  67. package/src/errors.test.ts +76 -0
  68. package/src/errors.ts +113 -0
  69. package/src/vercel/client/index.test.ts +47 -0
  70. package/src/vercel/client/messages.ts +20 -0
  71. package/src/vercel/client/search.test.ts +183 -8
  72. package/src/vercel/client/search.ts +46 -12
  73. package/src/vercel/client/start.ts +1 -0
  74. package/src/vercel/client/streamText.test.ts +113 -17
  75. package/src/vercel/client/streamText.ts +52 -28
  76. package/src/vercel/client/streaming.test.ts +71 -7
  77. package/src/vercel/client/streaming.ts +27 -7
  78. package/src/vercel/client/types.ts +2 -0
  79. package/src/vercel/client/utils.test.ts +97 -0
  80. package/src/vercel/client/utils.ts +15 -15
  81. package/src/vercel/index.ts +2 -0
@@ -0,0 +1,76 @@
1
+ import { APICallError } from "@ai-sdk/provider";
2
+ import { describe, expect, test } from "vitest";
3
+ import { errorToString } from "./errors.js";
4
+
5
+ describe("errorToString", () => {
6
+ test("preserves provider error classifications", () => {
7
+ const details = {
8
+ error: {
9
+ code: "invalid_prompt",
10
+ message: "Invalid prompt: flagged by policy",
11
+ },
12
+ };
13
+ const apiError = new APICallError({
14
+ message: "Invalid prompt: flagged by policy",
15
+ url: "https://api.example.test",
16
+ requestBodyValues: {},
17
+ statusCode: 400,
18
+ data: details,
19
+ });
20
+
21
+ expect(errorToString(details)).toBe(
22
+ "invalid_prompt: Invalid prompt: flagged by policy",
23
+ );
24
+ expect(errorToString(apiError)).toBe(
25
+ "invalid_prompt: Invalid prompt: flagged by policy",
26
+ );
27
+ expect(errorToString(new Error())).toBe("Error");
28
+ expect(errorToString(new TypeError())).toBe("TypeError");
29
+ const systemError = Object.assign(new Error("socket hang up"), {
30
+ code: "ECONNRESET",
31
+ });
32
+ expect(errorToString(systemError)).toBe("socket hang up");
33
+ const codeOnly = Object.assign(new Error("Request failed"), {
34
+ data: { code: "rate_limit" },
35
+ });
36
+ expect(errorToString(codeOnly)).toBe("rate_limit: Request failed");
37
+ });
38
+
39
+ test("serializes objects without mistaking shared values for cycles", () => {
40
+ const shared = { detail: "provider disconnected" };
41
+ const circular: Record<string, unknown> = { shared };
42
+ circular.self = circular;
43
+
44
+ expect(errorToString({ x: shared, y: shared })).toBe(
45
+ '{"x":{"detail":"provider disconnected"},"y":{"detail":"provider disconnected"}}',
46
+ );
47
+ expect(errorToString(circular)).toBe(
48
+ '{"shared":{"detail":"provider disconnected"},"self":"[Circular]"}',
49
+ );
50
+ });
51
+
52
+ test("bounds stored error text without splitting surrogate pairs", () => {
53
+ const serialized = errorToString(`${"x".repeat(1022)}😀tail`);
54
+
55
+ expect(serialized.length).toBeLessThanOrEqual(1024);
56
+ expect(serialized.endsWith("x…")).toBe(true);
57
+ });
58
+
59
+ test("does not throw when Error properties are hostile accessors", () => {
60
+ const error = new Error();
61
+ Object.defineProperties(error, {
62
+ message: {
63
+ get() {
64
+ throw new Error("message getter failed");
65
+ },
66
+ },
67
+ name: {
68
+ get() {
69
+ throw new Error("name getter failed");
70
+ },
71
+ },
72
+ });
73
+
74
+ expect(errorToString(error)).toBe("Unknown error");
75
+ });
76
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,113 @@
1
+ const MAX_ERROR_LENGTH = 1024;
2
+
3
+ export function errorToString(error: unknown): string {
4
+ return truncateError(describeError(error));
5
+ }
6
+
7
+ function describeError(error: unknown): string {
8
+ if (typeof error === "string") return error;
9
+ if (error instanceof Error) {
10
+ const message = property(error, "message");
11
+ if (typeof message !== "string" || message.length === 0) {
12
+ const name = property(error, "name");
13
+ return typeof name === "string" && name.length > 0
14
+ ? name
15
+ : safeString(error);
16
+ }
17
+ const nested = errorDetails(
18
+ property(error, "error") ?? property(error, "data"),
19
+ );
20
+ return (
21
+ formatDetails({
22
+ message: nested.message ?? message,
23
+ code: nested.code,
24
+ }) ?? message
25
+ );
26
+ }
27
+
28
+ const details = formatDetails(errorDetails(error));
29
+ if (details) return details;
30
+
31
+ if (error && typeof error === "object") {
32
+ try {
33
+ const ancestors: object[] = [];
34
+ const serialized = JSON.stringify(error, function (_key, value: unknown) {
35
+ if (typeof value === "bigint") return value.toString();
36
+ if (!value || typeof value !== "object") return value;
37
+ while (ancestors.length > 0 && ancestors.at(-1) !== this) {
38
+ ancestors.pop();
39
+ }
40
+ if (ancestors.includes(value)) return "[Circular]";
41
+ ancestors.push(value);
42
+ return value;
43
+ });
44
+ if (serialized) return serialized;
45
+ } catch {
46
+ return safeString(error);
47
+ }
48
+ }
49
+
50
+ return safeString(error);
51
+ }
52
+
53
+ function safeString(error: unknown): string {
54
+ try {
55
+ return String(error);
56
+ } catch {
57
+ return "Unknown error";
58
+ }
59
+ }
60
+
61
+ function errorDetails(error: unknown): { message?: string; code?: string } {
62
+ let current = error;
63
+ let message: string | undefined;
64
+ let code: string | undefined;
65
+ for (let depth = 0; depth < 3; depth++) {
66
+ if (typeof current === "string") {
67
+ message ??= current;
68
+ break;
69
+ }
70
+ if (!current || typeof current !== "object") break;
71
+
72
+ const currentMessage = property(current, "message");
73
+ if (typeof currentMessage === "string" && currentMessage.length > 0) {
74
+ message ??= currentMessage;
75
+ }
76
+ const currentCode = property(current, "code");
77
+ if (typeof currentCode === "string" || typeof currentCode === "number") {
78
+ code ??= String(currentCode);
79
+ }
80
+ if (message && code) break;
81
+ current = property(current, "error") ?? property(current, "data");
82
+ }
83
+ return { message, code };
84
+ }
85
+
86
+ function property(value: object, key: string): unknown {
87
+ try {
88
+ return (value as Record<string, unknown>)[key];
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ function formatDetails({
95
+ message,
96
+ code,
97
+ }: {
98
+ message?: string;
99
+ code?: string;
100
+ }): string | undefined {
101
+ if (message && code) {
102
+ return message.startsWith(`${code}:`) ? message : `${code}: ${message}`;
103
+ }
104
+ return message ?? code;
105
+ }
106
+
107
+ function truncateError(error: string): string {
108
+ if (error.length <= MAX_ERROR_LENGTH) return error;
109
+ let truncated = error.slice(0, MAX_ERROR_LENGTH - 1);
110
+ const last = truncated.charCodeAt(truncated.length - 1);
111
+ if (last >= 0xd800 && last <= 0xdbff) truncated = truncated.slice(0, -1);
112
+ return `${truncated}…`;
113
+ }
@@ -4,6 +4,8 @@ import {
4
4
  createThread,
5
5
  createTool,
6
6
  filterOutOrphanedToolMessages,
7
+ saveMessage,
8
+ toUIMessages,
7
9
  type MessageDoc,
8
10
  } from "../index.js";
9
11
  import type { DataModelFromSchemaDefinition } from "convex/server";
@@ -458,6 +460,51 @@ describe("Agent message operations", () => {
458
460
  expect(messages.length).toBe(2);
459
461
  expect(messages[1]._id).toBeDefined();
460
462
  });
463
+
464
+ test("saveMessage can place a standalone assistant message on a new order", async () => {
465
+ const t = initConvexTest(schema);
466
+ const threadId = await t.run(async (ctx) =>
467
+ createThread(ctx, components.agent, { userId: "operator-test" }),
468
+ );
469
+ const { message: agentReply } = await t.run(async (ctx) =>
470
+ agent.saveMessage(ctx, {
471
+ threadId,
472
+ message: {
473
+ role: "assistant",
474
+ content: [
475
+ {
476
+ type: "tool-call",
477
+ toolCallId: "handoff-1",
478
+ toolName: "handoff",
479
+ input: {},
480
+ },
481
+ ],
482
+ },
483
+ }),
484
+ );
485
+ const { message: operatorReply } = await t.run(async (ctx) =>
486
+ saveMessage(ctx, components.agent, {
487
+ threadId,
488
+ order: "next",
489
+ agentName: "human:Alex",
490
+ message: { role: "assistant", content: "Operator reply" },
491
+ }),
492
+ );
493
+ const uiMessages = toUIMessages([agentReply, operatorReply]);
494
+
495
+ expect(agentReply).toMatchObject({ order: 0, stepOrder: 0 });
496
+ expect(operatorReply).toMatchObject({
497
+ order: 1,
498
+ stepOrder: 0,
499
+ agentName: "human:Alex",
500
+ });
501
+ expect(uiMessages).toHaveLength(2);
502
+ expect(uiMessages[1]).toMatchObject({
503
+ order: 1,
504
+ agentName: "human:Alex",
505
+ text: "Operator reply",
506
+ });
507
+ });
461
508
  });
462
509
 
463
510
  describe("Agent text/object generation", () => {
@@ -11,6 +11,7 @@ import { toUIMessages, type UIMessage } from "../UIMessages.js";
11
11
  import {
12
12
  listMessages,
13
13
  saveMessages as saveCanonicalMessages,
14
+ type MessageOrder,
14
15
  } from "../../client/messages.js";
15
16
  import type {
16
17
  AgentComponent,
@@ -36,6 +37,15 @@ export async function listUIMessages(
36
37
  export type SaveMessagesArgs = {
37
38
  threadId: string;
38
39
  userId?: string | null;
40
+ /**
41
+ * Save the first message at this order. Pass `"next"` to allocate a new
42
+ * order after the current latest message. If the numeric order already
43
+ * contains messages, the message is appended at the next stepOrder.
44
+ * Numeric orders must be non-negative safe integers less than
45
+ * Number.MAX_SAFE_INTEGER.
46
+ * Cannot be combined with promptMessageId or pendingMessageId.
47
+ */
48
+ order?: MessageOrder;
39
49
  /**
40
50
  * The message that these messages are in response to. They will be
41
51
  * the same "order" as this message, at increasing stepOrder(s).
@@ -86,6 +96,7 @@ export async function saveMessages(
86
96
  userId: args.userId ?? undefined,
87
97
  agentName: args.agentName,
88
98
  promptMessageId: args.promptMessageId,
99
+ order: args.order,
89
100
  pendingMessageId: args.pendingMessageId,
90
101
  embeddings: args.embeddings,
91
102
  messages: serialized.map(({ message }) => message),
@@ -105,6 +116,14 @@ export async function saveMessages(
105
116
  export type SaveMessageArgs = {
106
117
  threadId: string;
107
118
  userId?: string | null;
119
+ /**
120
+ * Save the message at this order. Pass `"next"` to allocate a new order
121
+ * after the current latest message. If the numeric order already contains
122
+ * messages, the message is appended at the next stepOrder. Numeric orders
123
+ * must be non-negative safe integers less than Number.MAX_SAFE_INTEGER.
124
+ * Cannot be combined with promptMessageId or pendingMessageId.
125
+ */
126
+ order?: MessageOrder;
108
127
  /**
109
128
  * The message that these messages are in response to. They will be
110
129
  * the same "order" as this message, at increasing stepOrder(s).
@@ -169,6 +188,7 @@ export async function saveMessage(
169
188
  userId: args.userId ?? undefined,
170
189
  agentName: args.agentName,
171
190
  promptMessageId: args.promptMessageId,
191
+ order: args.order,
172
192
  pendingMessageId: args.pendingMessageId,
173
193
  messages:
174
194
  args.prompt !== undefined
@@ -20,6 +20,7 @@ import {
20
20
  fetchContextMessages,
21
21
  filterOutOrphanedToolMessages,
22
22
  getPromptArray,
23
+ trimIncompleteOldestOrder,
23
24
  } from "./search.js";
24
25
  import { components, initConvexTest } from "./setup.test.js";
25
26
  import { createThread } from "../../client/threads.js";
@@ -31,13 +32,14 @@ const createMockMessageDoc = (
31
32
  role: "user" | "assistant" | "tool" | "system",
32
33
  content: any,
33
34
  order: number = 1,
35
+ stepOrder: number = 0,
34
36
  ): MessageDoc => ({
35
37
  _id: id,
36
38
  _creationTime: Date.now(),
37
39
  userId: "test-user",
38
40
  threadId: "test-thread",
39
41
  order,
40
- stepOrder: order,
42
+ stepOrder,
41
43
  status: "success",
42
44
  tool: false,
43
45
  message: { role, content },
@@ -127,6 +129,27 @@ describe("search.ts", () => {
127
129
  });
128
130
  });
129
131
 
132
+ describe("trimIncompleteOldestOrder", () => {
133
+ it("keeps a window that begins at the start of an order", () => {
134
+ const messages = [
135
+ createMockMessageDoc("user", "user", "Question", 1, 0),
136
+ createMockMessageDoc("assistant", "assistant", "Answer", 1, 1),
137
+ ];
138
+
139
+ expect(trimIncompleteOldestOrder(messages)).toEqual(messages);
140
+ });
141
+
142
+ it("omits only the incomplete oldest order", () => {
143
+ const messages = [
144
+ createMockMessageDoc("partial", "assistant", "Partial", 1, 2),
145
+ createMockMessageDoc("next-user", "user", "Next question", 2, 0),
146
+ createMockMessageDoc("next-answer", "assistant", "Next answer", 2, 1),
147
+ ];
148
+
149
+ expect(trimIncompleteOldestOrder(messages)).toEqual(messages.slice(1));
150
+ });
151
+ });
152
+
130
153
  describe("filterOutOrphanedToolMessages", () => {
131
154
  it("should keep non-tool messages", () => {
132
155
  const messages: MessageDoc[] = [
@@ -442,6 +465,7 @@ describe("search.ts", () => {
442
465
  expect(result.length).toBe(2);
443
466
  expect(result[0]._id).toBe("1"); // Should be reversed back to asc order
444
467
  expect(result[1]._id).toBe("2");
468
+ expect(mockCtx.runQuery).toHaveBeenCalledTimes(1);
445
469
  });
446
470
 
447
471
  it("should skip recent messages when recentMessages is 0", async () => {
@@ -560,11 +584,13 @@ describe("search.ts", () => {
560
584
  _id: "ctx1",
561
585
  message: { role: "user", content: "Context message 1" },
562
586
  order: 1,
587
+ stepOrder: 0,
563
588
  } as MessageDoc,
564
589
  {
565
590
  _id: "ctx2",
566
591
  message: { role: "assistant", content: "Context response 1" },
567
592
  order: 2,
593
+ stepOrder: 0,
568
594
  } as MessageDoc,
569
595
  ];
570
596
 
@@ -590,6 +616,143 @@ describe("search.ts", () => {
590
616
  });
591
617
  });
592
618
 
619
+ it("should complete an order truncated by the recent messages limit", async () => {
620
+ const toolCallId = "call_issue_309";
621
+ vi.mocked(mockCtx.runQuery)
622
+ .mockResolvedValueOnce({
623
+ page: [
624
+ createMockMessageDoc(
625
+ "assistant-final",
626
+ "assistant",
627
+ [{ type: "text", text: "The result is 72." }],
628
+ 1,
629
+ 3,
630
+ ),
631
+ createMockMessageDoc(
632
+ "tool-result",
633
+ "tool",
634
+ [
635
+ {
636
+ type: "tool-result",
637
+ toolCallId,
638
+ toolName: "getWeather",
639
+ output: {
640
+ type: "json",
641
+ value: { temperature: 72 },
642
+ },
643
+ },
644
+ ],
645
+ 1,
646
+ 2,
647
+ ),
648
+ createMockMessageDoc(
649
+ "assistant-tool-call",
650
+ "assistant",
651
+ [
652
+ {
653
+ type: "tool-call",
654
+ toolCallId,
655
+ toolName: "getWeather",
656
+ input: { city: "New York" },
657
+ },
658
+ ],
659
+ 1,
660
+ 1,
661
+ ),
662
+ ],
663
+ continueCursor: "complete-order",
664
+ isDone: false,
665
+ })
666
+ .mockResolvedValueOnce({
667
+ page: [
668
+ createMockMessageDoc(
669
+ "user",
670
+ "user",
671
+ "What is the weather?",
672
+ 1,
673
+ 0,
674
+ ),
675
+ ],
676
+ continueCursor: "done",
677
+ isDone: true,
678
+ });
679
+
680
+ const result = await fetchContextWithPrompt(
681
+ mockCtx,
682
+ components.agent,
683
+ {
684
+ ...baseArgs,
685
+ prompt: "What should I do next?",
686
+ messages: undefined,
687
+ promptMessageId: undefined,
688
+ contextOptions: { recentMessages: 3 },
689
+ },
690
+ );
691
+
692
+ expect(result.messages.map((message) => message.role)).toEqual([
693
+ "user",
694
+ "assistant",
695
+ "tool",
696
+ "assistant",
697
+ "user",
698
+ ]);
699
+ expect(mockCtx.runQuery).toHaveBeenNthCalledWith(2, expect.anything(), {
700
+ threadId: "thread123",
701
+ excludeToolMessages: undefined,
702
+ paginationOpts: { numItems: 1, cursor: "complete-order" },
703
+ upToAndIncludingMessageId: undefined,
704
+ order: "desc",
705
+ statuses: ["success"],
706
+ });
707
+ });
708
+
709
+ it("should omit an order when its boundary cannot be fetched", async () => {
710
+ vi.mocked(mockCtx.runQuery)
711
+ .mockResolvedValueOnce({
712
+ page: [
713
+ createMockMessageDoc(
714
+ "assistant-final",
715
+ "assistant",
716
+ "Partial answer",
717
+ 1,
718
+ 2,
719
+ ),
720
+ ],
721
+ continueCursor: "complete-order",
722
+ isDone: false,
723
+ })
724
+ .mockResolvedValueOnce({
725
+ page: [
726
+ createMockMessageDoc(
727
+ "assistant-step",
728
+ "assistant",
729
+ "Still partial",
730
+ 1,
731
+ 1,
732
+ ),
733
+ ],
734
+ continueCursor: "done",
735
+ isDone: true,
736
+ });
737
+
738
+ const result = await fetchContextWithPrompt(
739
+ mockCtx,
740
+ components.agent,
741
+ {
742
+ ...baseArgs,
743
+ prompt: "New prompt",
744
+ messages: undefined,
745
+ promptMessageId: undefined,
746
+ contextOptions: { recentMessages: 1 },
747
+ },
748
+ );
749
+
750
+ expect(result.messages).toEqual([
751
+ { role: "user", content: "New prompt" },
752
+ ]);
753
+ expect(mockCtx.runQuery).toHaveBeenCalledTimes(2);
754
+ });
755
+
593
756
  it("should handle input messages correctly", async () => {
594
757
  const inputMessages: ModelMessage[] = [
595
758
  { role: "user", content: "Input message 1" },
@@ -618,16 +781,19 @@ describe("search.ts", () => {
618
781
  _id: "msg1",
619
782
  message: { role: "user", content: "Before prompt" },
620
783
  order: 1,
784
+ stepOrder: 0,
621
785
  } as MessageDoc,
622
786
  {
623
787
  _id: "prompt-msg",
624
788
  message: { role: "user", content: "Original prompt" },
625
789
  order: 2,
790
+ stepOrder: 0,
626
791
  } as MessageDoc,
627
792
  {
628
793
  _id: "msg3",
629
794
  message: { role: "assistant", content: "After prompt" },
630
795
  order: 3,
796
+ stepOrder: 0,
631
797
  } as MessageDoc,
632
798
  ];
633
799
 
@@ -659,16 +825,19 @@ describe("search.ts", () => {
659
825
  _id: "msg1",
660
826
  message: { role: "user", content: "Before prompt" },
661
827
  order: 1,
828
+ stepOrder: 0,
662
829
  } as MessageDoc,
663
830
  {
664
831
  _id: "prompt-msg",
665
832
  message: { role: "user", content: "Original prompt" },
666
833
  order: 2,
834
+ stepOrder: 0,
667
835
  } as MessageDoc,
668
836
  {
669
837
  _id: "msg3",
670
838
  message: { role: "assistant", content: "After prompt" },
671
839
  order: 3,
840
+ stepOrder: 0,
672
841
  } as MessageDoc,
673
842
  ];
674
843
 
@@ -696,16 +865,19 @@ describe("search.ts", () => {
696
865
  _id: "ctx1",
697
866
  message: { role: "user", content: "Context 1" },
698
867
  order: 1,
868
+ stepOrder: 0,
699
869
  } as MessageDoc,
700
870
  {
701
871
  _id: "prompt-msg",
702
872
  message: { role: "user", content: "Prompt" },
703
873
  order: 3,
874
+ stepOrder: 0,
704
875
  } as MessageDoc,
705
876
  {
706
877
  _id: "ctx2",
707
878
  message: { role: "assistant", content: "Context 2" },
708
879
  order: 5,
880
+ stepOrder: 0,
709
881
  } as MessageDoc,
710
882
  ];
711
883
 
@@ -864,10 +1036,10 @@ describe("search.ts", () => {
864
1036
  });
865
1037
  });
866
1038
 
867
- it("should respect recentMessages limit", async () => {
1039
+ it("should extend recentMessages to complete an order", async () => {
868
1040
  const threadId = await createTestThread("user999");
869
1041
 
870
- // Create 5 messages but only fetch the most recent 2
1042
+ // Start with a two-message window that splits the previous order.
871
1043
  await createTestMessages(threadId, "user999", [
872
1044
  { role: "user", content: "Message 1", order: 1 },
873
1045
  { role: "assistant", content: "Response 1", order: 2 },
@@ -883,13 +1055,16 @@ describe("search.ts", () => {
883
1055
  prompt: "New prompt",
884
1056
  messages: undefined,
885
1057
  promptMessageId: undefined,
886
- contextOptions: { recentMessages: 2 }, // Only fetch 2 most recent
1058
+ contextOptions: { recentMessages: 2 },
887
1059
  });
888
1060
 
889
- expect(result.messages).toHaveLength(3); // 2 context + 1 prompt
890
- expect(result.messages[0].content).toBe("Response 2"); // 4th message
891
- expect(result.messages[1].content).toBe("Message 3"); // 5th message
892
- expect(result.messages[2]).toEqual({
1061
+ // The limit splits the previous order after its user message, so the
1062
+ // context extends backward to include the whole order.
1063
+ expect(result.messages).toHaveLength(4);
1064
+ expect(result.messages[0].content).toBe("Message 2");
1065
+ expect(result.messages[1].content).toBe("Response 2");
1066
+ expect(result.messages[2].content).toBe("Message 3");
1067
+ expect(result.messages[3]).toEqual({
893
1068
  role: "user",
894
1069
  content: "New prompt",
895
1070
  });
@@ -36,10 +36,24 @@ import {
36
36
  } from "../mapping.js";
37
37
 
38
38
  const DEFAULT_VECTOR_SCORE_THRESHOLD = 0.0;
39
+ // Bound the rare boundary-extension query; incomplete orders are trimmed below.
40
+ const MAX_ORDER_COMPLETION_MESSAGES = 1_000;
39
41
  // 10k characters should be more than enough for most cases, and stays under
40
42
  // the 8k token limit for some models.
41
43
  const MAX_EMBEDDING_TEXT_LENGTH = 10_000;
42
44
 
45
+ /**
46
+ * Omit the oldest order when a descending pagination window split it.
47
+ * Expects the messages to be sorted in ascending order.
48
+ */
49
+ export function trimIncompleteOldestOrder(docs: MessageDoc[]) {
50
+ const oldest = docs[0];
51
+ if (!oldest || oldest.stepOrder === 0) {
52
+ return docs;
53
+ }
54
+ return docs.filter((doc) => doc.order !== oldest.order);
55
+ }
56
+
43
57
  export type GetEmbedding = (text: string) => Promise<
44
58
  | {
45
59
  embedding: number[];
@@ -133,25 +147,45 @@ export async function fetchRecentAndSearchMessages(
133
147
  let included: Set<string> | undefined;
134
148
  let recentMessages: MessageDoc[] = [];
135
149
  let searchMessages: MessageDoc[] = [];
150
+ const threadId = args.threadId;
136
151
  const targetMessageId =
137
152
  args.targetMessageId ?? args.upToAndIncludingMessageId;
138
- if (args.threadId && opts.recentMessages !== 0) {
139
- const { page } = await ctx.runQuery(
140
- component.messages.listMessagesByThreadId,
141
- {
142
- threadId: args.threadId,
153
+ if (threadId && opts.recentMessages !== 0) {
154
+ const fetchRecentPage = (numItems: number, cursor: string | null) =>
155
+ ctx.runQuery(component.messages.listMessagesByThreadId, {
156
+ threadId,
143
157
  excludeToolMessages: opts.excludeToolMessages,
144
- paginationOpts: {
145
- numItems: opts.recentMessages ?? DEFAULT_RECENT_MESSAGES,
146
- cursor: null,
147
- },
158
+ paginationOpts: { numItems, cursor },
148
159
  upToAndIncludingMessageId: targetMessageId,
149
160
  order: "desc",
150
161
  statuses: ["success"],
151
- },
162
+ });
163
+ const firstPage = await fetchRecentPage(
164
+ opts.recentMessages ?? DEFAULT_RECENT_MESSAGES,
165
+ null,
152
166
  );
153
- included = new Set(page.map((m) => m._id));
154
- recentMessages = filterOutOrphanedToolMessages(sorted(page));
167
+ let page = firstPage.page;
168
+ const oldest = page.at(-1);
169
+ if (
170
+ oldest &&
171
+ oldest.stepOrder > 0 &&
172
+ !firstPage.isDone &&
173
+ firstPage.continueCursor
174
+ ) {
175
+ const completionPage = await fetchRecentPage(
176
+ Math.min(oldest.stepOrder, MAX_ORDER_COMPLETION_MESSAGES),
177
+ firstPage.continueCursor,
178
+ );
179
+ page = [
180
+ ...page,
181
+ ...completionPage.page.filter(
182
+ (message) => message.order === oldest.order,
183
+ ),
184
+ ];
185
+ }
186
+ const retained = trimIncompleteOldestOrder(sorted(page));
187
+ included = new Set(retained.map((m) => m._id));
188
+ recentMessages = filterOutOrphanedToolMessages(retained);
155
189
  }
156
190
  if (
157
191
  (opts.searchOptions?.textSearch || opts.searchOptions?.vectorSearch) &&