From 8d389bc163ef89e3a0fe11c6fbd7d3ecda3922de Mon Sep 17 00:00:00 2001 From: Seth Raphael Date: Sat, 9 May 2026 16:16:08 -0600 Subject: [PATCH 1/4] fix three UI/streaming/search bugs - createUserUIMessage: spread partCommon on string content path so providerMetadata and streaming state survive onto text parts (#234). - willContinue: count tool-error parts as completed outputs so the agent doesn't stop mid-step when a tool throws under AI SDK v6 (#240). - textSearch: skip the targetMessage.order filter for messages from other threads when searching across a userId, since order is only meaningful within a single thread (#256). --- src/UIMessages.ts | 2 +- src/component/messages.ts | 18 ++++++++++++------ src/toUIMessages.test.ts | 6 +++++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/UIMessages.ts b/src/UIMessages.ts index 25a1537a..08428f13 100644 --- a/src/UIMessages.ts +++ b/src/UIMessages.ts @@ -337,7 +337,7 @@ function createUserUIMessage< const parts: UIMessage["parts"] = []; if (text && !nonStringContent.length) { - parts.push({ type: "text", text }); + parts.push({ type: "text", text, ...partCommon }); } for (const contentPart of nonStringContent) { switch (contentPart.type) { diff --git a/src/component/messages.ts b/src/component/messages.ts index 1f38ec87..448732de 100644 --- a/src/component/messages.ts +++ b/src/component/messages.ts @@ -928,20 +928,26 @@ export const textSearch = query({ // Just in case tool messages slip through .filter((q) => { const qq = q.eq(q.field("tool"), false); - if (order) { + // Order is only comparable within a single thread, so when searching + // across threads for a user we skip the DB-level order filter and + // apply it per-thread below. + if (order && !args.searchAllMessagesForUserId) { return q.and(qq, q.lte(q.field("order"), order)); } return qq; }) .take(args.limit); return messages - .filter( - (m) => - !targetMessage || + .filter((m) => { + if (!targetMessage) return true; + // Different threads have independent order sequences; don't compare across them. + 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), diff --git a/src/toUIMessages.test.ts b/src/toUIMessages.test.ts index 8c5dfca1..de103910 100644 --- a/src/toUIMessages.test.ts +++ b/src/toUIMessages.test.ts @@ -32,7 +32,11 @@ describe("toUIMessages", () => { expect(uiMessages).toHaveLength(1); expect(uiMessages[0].role).toBe("user"); expect(uiMessages[0].text).toBe("Hello!"); - expect(uiMessages[0].parts[0]).toEqual({ type: "text", text: "Hello!" }); + expect(uiMessages[0].parts[0]).toEqual({ + type: "text", + text: "Hello!", + state: "done", + }); }); it("handles assistant message", () => { From e1164373f1bfd8521620f3f304b5273f8ac592c8 Mon Sep 17 00:00:00 2001 From: Seth Raphael Date: Sat, 9 May 2026 22:20:04 -0600 Subject: [PATCH 2/4] add cross-thread textSearch test and document take-limit tradeoff - Adds a regression test for #256 that constructs a target message at order 2 in one thread and a matching message at order 5 in another thread under the same userId. Verified that the test fails against the pre-fix code (which dropped the cross-thread match via the DB-level lte(order) filter) and passes after the fix. - Adds a comment in textSearch noting that with the DB-level order filter removed for cross-thread search, in-thread matches with order >= target.order can be edged out of the take-limit by cross-thread matches. Acceptable tradeoff for now; documented for follow-up. --- src/component/messages.test.ts | 59 ++++++++++++++++++++++++++++++++++ src/component/messages.ts | 5 +++ 2 files changed, 64 insertions(+) diff --git a/src/component/messages.test.ts b/src/component/messages.test.ts index e557f4f7..ead2cc44 100644 --- a/src/component/messages.test.ts +++ b/src/component/messages.test.ts @@ -623,6 +623,65 @@ describe("agent", () => { expect(result.lastStepOrder).toBe(1); }); + test("textSearch returns cross-thread matches even when target order is lower (#256)", async () => { + const t = initConvexTest(); + const userId = "search-user-1"; + + // Old thread: matching message lives at order 5. + const threadA = await t.mutation(api.threads.createThread, { userId }); + for (let i = 0; i < 5; i++) { + await t.mutation(api.messages.addMessages, { + threadId: threadA._id as Id<"threads">, + messages: [{ message: { role: "user", content: `padding ${i}` } }], + }); + } + await t.mutation(api.messages.addMessages, { + threadId: threadA._id as Id<"threads">, + messages: [ + { message: { role: "user", content: "high-ticket coaches are great" } }, + ], + }); + + // New thread: target lives at order 2 — non-zero so the buggy DB-level + // `lte(order)` filter actually engages and drops threadA's order-5 match. + const threadB = await t.mutation(api.threads.createThread, { userId }); + await t.mutation(api.messages.addMessages, { + threadId: threadB._id as Id<"threads">, + messages: [{ message: { role: "user", content: "intro one" } }], + }); + await t.mutation(api.messages.addMessages, { + threadId: threadB._id as Id<"threads">, + messages: [{ message: { role: "user", content: "intro two" } }], + }); + const { messages: targetMsgs } = await t.mutation( + api.messages.addMessages, + { + threadId: threadB._id as Id<"threads">, + messages: [ + { message: { role: "user", content: "high-ticket coaches" } }, + ], + }, + ); + const targetMessageId = targetMsgs[0]._id as Id<"messages">; + + const results = await t.query(api.messages.textSearch, { + searchAllMessagesForUserId: userId, + text: "high-ticket coaches", + targetMessageId, + limit: 10, + }); + + // Pre-fix, threadA's match (order 5) was dropped because the DB-level + // `lte(order, 2)` filter was applied to all threads using threadB's + // target order. Post-fix, cross-thread results bypass that filter. + const fromThreadA = results.filter((m) => m.threadId === threadA._id); + expect(fromThreadA.length).toBeGreaterThan(0); + + // The target message itself must still be excluded from its own thread's results. + const targetInResults = results.find((m) => m._id === targetMessageId); + expect(targetInResults).toBeUndefined(); + }); + test("deleteByOrder handles empty result set", async () => { const t = convexTest(schema, modules); const thread = await t.mutation(api.threads.createThread, { diff --git a/src/component/messages.ts b/src/component/messages.ts index 448732de..441c6a57 100644 --- a/src/component/messages.ts +++ b/src/component/messages.ts @@ -937,6 +937,11 @@ export const textSearch = query({ return qq; }) .take(args.limit); + // Tradeoff for cross-thread search: in-thread matches with order >= + // targetMessage.order are dropped here rather than at the DB layer, so + // when there are many cross-thread matches they can edge out valid + // older in-thread matches. Acceptable for now; revisit with a merged + // per-thread + cross-thread query if it becomes an issue in practice. return messages .filter((m) => { if (!targetMessage) return true; From 124dbf9fc3f6cfe290256889ff016f5ec689b0c4 Mon Sep 17 00:00:00 2001 From: Ian Macartney <366683+ianmacartney@users.noreply.github.com> Date: Tue, 5 May 2026 01:42:19 -0700 Subject: [PATCH 3/4] update convex-test --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49a3ce1c..e9453db6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,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", @@ -4733,9 +4733,9 @@ } }, "node_modules/convex-test": { - "version": "0.0.44", - "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.44.tgz", - "integrity": "sha512-2tv5XRx1n9z4kKwIiUU1UZgC0UDgUGpqAL2D2Hq45cHJ6HBTemfP2wKPMOcfag6ATFfmyqK3Xau0fKiJCtrdxQ==", + "version": "0.0.51", + "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.51.tgz", + "integrity": "sha512-J+4YRpKGXJDfnQqiWUUT+ylNmNO36MpkuwqG3JG4ld+7QtroZGF8HqO4qzMmfv5ltm71rPbkBvi//MoMHjnVvQ==", "dev": true, "license": "Apache-2.0", "peerDependencies": { diff --git a/package.json b/package.json index a40417de..79586704 100644 --- a/package.json +++ b/package.json @@ -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", From 957169eed23e7be0e0d720d689f0b527486299b5 Mon Sep 17 00:00:00 2001 From: Ian Macartney <366683+ianmacartney@users.noreply.github.com> Date: Wed, 13 May 2026 00:07:29 -0700 Subject: [PATCH 4/4] fix: apply same-thread order filter in vector search path (#256) The vector-search code path in _fetchSearchMessages had the same cross-thread defect as textSearch: when searching across threads with searchAllMessagesForUserId, results were filtered against the target message's order, which only makes sense within a single thread. Mirror the textSearch fix so cross-thread vector hits pass through the beforeMessage order check. --- src/component/messages.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/component/messages.ts b/src/component/messages.ts index 441c6a57..486bd881 100644 --- a/src/component/messages.ts +++ b/src/component/messages.ts @@ -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; + // Order is only comparable within a single thread; cross-thread + // results have independent order sequences and must pass through. + 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