Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
"clsx": "2.1.1",
"convex": "1.35.1",
"convex-helpers": "0.1.114",
"convex-test": "0.0.44",
"convex-test": "0.0.51",
"dayjs": "1.11.20",
"dotenv": "16.6.1",
"eslint": "9.39.4",
Expand Down
120 changes: 120 additions & 0 deletions src/component/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,4 +652,124 @@ describe("agent", () => {
);
expect(remainingMessages.page).toHaveLength(1);
});

// Regression test for #256: when searching across threads with
// searchAllMessagesForUserId, the targetMessage's order should not filter
// out messages from other threads (their order sequences are independent).
test("textSearch returns cross-thread matches even when target order is lower", async () => {
const t = convexTest(schema, modules);
const userId = "user-256";

// Old thread: build up several messages so the matching one has a high order.
const oldThread = await t.mutation(api.threads.createThread, { userId });
for (let i = 0; i < 5; i++) {
await t.mutation(api.messages.addMessages, {
threadId: oldThread._id as Id<"threads">,
userId,
messages: [
{ message: { role: "user", content: `filler message ${i}` } },
],
});
}
await t.mutation(api.messages.addMessages, {
threadId: oldThread._id as Id<"threads">,
userId,
messages: [
{
message: {
role: "user",
content:
"tom and jerry are both amazing high-ticket coaches and educators",
},
},
],
});

// New thread: only one message — its order will be 0, lower than the
// matching message in the old thread.
const newThread = await t.mutation(api.threads.createThread, { userId });
const { messages: newMessages } = await t.mutation(
api.messages.addMessages,
{
threadId: newThread._id as Id<"threads">,
userId,
messages: [
{
message: {
role: "user",
content: "what do you remember about high-ticket coaches",
},
},
],
},
);
const targetMessageId = newMessages[0]._id as Id<"messages">;

const results = await t.query(api.messages.textSearch, {
searchAllMessagesForUserId: userId,
targetMessageId,
text: "high-ticket coaches",
limit: 10,
});

// The cross-thread match should NOT be filtered out by the target's order.
expect(
results.some((m) =>
m.text?.includes("tom and jerry are both amazing high-ticket coaches"),
),
).toBe(true);
// The target message itself must still be excluded.
expect(results.some((m) => m._id === targetMessageId)).toBe(false);
});

// Regression test for #256: same-thread order filter must still work even
// when searching across threads.
test("textSearch still filters same-thread results past the target order", async () => {
const t = convexTest(schema, modules);
const userId = "user-256-same";

const thread = await t.mutation(api.threads.createThread, { userId });
// earlier match
await t.mutation(api.messages.addMessages, {
threadId: thread._id as Id<"threads">,
userId,
messages: [
{ message: { role: "user", content: "earlier high-ticket match" } },
],
});
// target
const { messages: targetMessages } = await t.mutation(
api.messages.addMessages,
{
threadId: thread._id as Id<"threads">,
userId,
messages: [
{ message: { role: "user", content: "target high-ticket message" } },
],
},
);
// later match in same thread — should be filtered out
await t.mutation(api.messages.addMessages, {
threadId: thread._id as Id<"threads">,
userId,
messages: [
{ message: { role: "user", content: "later high-ticket match" } },
],
});

const results = await t.query(api.messages.textSearch, {
searchAllMessagesForUserId: userId,
targetMessageId: targetMessages[0]._id as Id<"messages">,
text: "high-ticket",
limit: 10,
});

expect(results.some((m) => m.text === "earlier high-ticket match")).toBe(
true,
);
expect(results.some((m) => m.text === "later high-ticket match")).toBe(
false,
);
expect(results.some((m) => m._id === targetMessages[0]._id)).toBe(false);
});
});
47 changes: 29 additions & 18 deletions src/component/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,16 +821,18 @@ export const _fetchSearchMessages = internalQuery({
),
)
)
.filter(
(m): m is Doc<"messages"> =>
m !== undefined &&
m !== null &&
!m.tool &&
(!beforeMessage ||
m.order < beforeMessage.order ||
(m.order === beforeMessage.order &&
m.stepOrder < beforeMessage.stepOrder)),
)
.filter((m): m is Doc<"messages"> => {
if (m === undefined || m === null || m.tool) return false;
if (!beforeMessage) return true;
// The order filter is only meaningful within the same thread.
// Messages from other threads have independent order sequences.
if (m.threadId !== beforeMessage.threadId) return true;
return (
m.order < beforeMessage.order ||
(m.order === beforeMessage.order &&
m.stepOrder < beforeMessage.stepOrder)
);
})
.map(publicMessage);
messages.push(...(args.textSearchMessages ?? []));
// TODO: prioritize more recent messages
Expand Down Expand Up @@ -912,12 +914,17 @@ export const textSearch = query({
);
const targetMessage =
args.targetMessageId && (await ctx.db.get(args.targetMessageId));
const order = targetMessage?.order;
const text = args.text || targetMessage?.text;
if (!text) {
console.warn("No text to search", targetMessage, args.text);
return [];
}
// When searching across threads (searchAllMessagesForUserId), the
// targetMessage's order is only meaningful within its own thread, so we
// can't apply it as a database-level filter. We still apply it post-fetch
// for same-thread results below.
const restrictOrderInDb =
!args.searchAllMessagesForUserId && targetMessage;
const messages = await ctx.db
.query("messages")
.withSearchIndex("text_search", (q) =>
Expand All @@ -928,20 +935,24 @@ export const textSearch = query({
// Just in case tool messages slip through
.filter((q) => {
const qq = q.eq(q.field("tool"), false);
if (order) {
return q.and(qq, q.lte(q.field("order"), order));
if (restrictOrderInDb) {
return q.and(qq, q.lte(q.field("order"), targetMessage.order));
}
return qq;
})
.take(args.limit);
return messages
.filter(
(m) =>
!targetMessage ||
.filter((m) => {
if (!targetMessage) return true;
// Order is only meaningful within the same thread; cross-thread
// results have independent order sequences and should pass through.
if (m.threadId !== targetMessage.threadId) return true;
return (
m.order < targetMessage.order ||
(m.order === targetMessage.order &&
m.stepOrder < targetMessage.stepOrder),
)
m.stepOrder < targetMessage.stepOrder)
);
})
.map(publicMessage);
},
returns: v.array(vMessageDoc),
Expand Down
Loading