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
11 changes: 8 additions & 3 deletions src/vercel/react/useThreadMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
type ErrorMessage,
type Expand,
} from "convex-helpers";
import { usePaginatedQuery } from "convex-helpers/react";
import {
type PaginatedQueryArgs,
type UsePaginatedQueryResult,
Expand All @@ -28,6 +27,7 @@ import type {
} from "../../validators.js";
import type { StreamQueryArgs, StreamQuery } from "./types.js";
import { useStreamingUIMessages } from "./useStreamingUIMessages.js";
import { usePaginatedMessages } from "./useUIMessages.js";

export type MessageDocLike = {
order: number;
Expand Down Expand Up @@ -120,6 +120,11 @@ export function useThreadMessages<Query extends ThreadMessagesQuery<any, any>>(
query: Query,
args: ThreadMessagesArgs<Query> | "skip",
options: {
/**
* Minimum page size. The hook may load additional records, and briefly
* report "LoadingMore", when pagination splits a message across canonical
* rows sharing one `order`.
*/
initialNumItems: number;
stream?: Query extends StreamQuery
? boolean
Expand All @@ -129,10 +134,10 @@ export function useThreadMessages<Query extends ThreadMessagesQuery<any, any>>(
ThreadMessagesResult<Query> & { streaming: boolean; key: string }
> {
// These are full messages
const paginated = usePaginatedQuery(
const paginated = usePaginatedMessages(
query,
args as PaginatedQueryArgs<Query> | "skip",
{ initialNumItems: options.initialNumItems },
options.initialNumItems,
);

let startOrder = paginated.results.at(-1)?.order ?? 0;
Expand Down
177 changes: 177 additions & 0 deletions src/vercel/react/useUIMessages.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, it, expect } from "vitest";
import { toUIMessages } from "../UIMessages.js";
import type { MessageDoc } from "../../validators.js";
import {
dedupeMessages,
mergeUIMessages,
planOldestOrderCompletion,
type UIMessageLike,
} from "./useUIMessages.js";

Expand Down Expand Up @@ -45,6 +48,86 @@ function testUIMessage({
};
}

describe("paginated UI message boundaries", () => {
const partialOrder = [
{ order: 4, stepOrder: 2 },
{ order: 5, stepOrder: 0 },
];

it("tracks one incomplete order across follow-up pages", () => {
expect(
planOldestOrderCompletion(partialOrder, "CanLoadMore", "ready"),
).toEqual({ order: 4, loadMore: true });
expect(
planOldestOrderCompletion(
[...partialOrder, { order: 4, stepOrder: 1 }],
"CanLoadMore",
4,
),
).toEqual({ order: 4, loadMore: true });
});

it("stops after completing the target instead of chasing a new boundary", () => {
expect(
planOldestOrderCompletion(
[
{ order: 4, stepOrder: 0 },
{ order: 4, stepOrder: 2 },
{ order: 3, stepOrder: 2 },
],
"CanLoadMore",
4,
),
).toEqual({ order: "idle", loadMore: false });
});

it("stops if a custom query filters out the target's step zero", () => {
expect(
planOldestOrderCompletion(
[...partialOrder, { order: 3, stepOrder: 2 }],
"CanLoadMore",
4,
),
).toEqual({ order: "idle", loadMore: false });
});

it("uses loading status to distinguish automatic and caller pages", () => {
expect(planOldestOrderCompletion(partialOrder, "LoadingMore", 4)).toEqual({
order: 4,
loadMore: false,
});
expect(
planOldestOrderCompletion(partialOrder, "LoadingMore", "idle"),
).toEqual({ order: "ready", loadMore: false });
});

it("resets with a new first page and ignores an already complete boundary", () => {
expect(
planOldestOrderCompletion(partialOrder, "LoadingFirstPage", 4),
).toEqual({ order: "ready", loadMore: false });
expect(
planOldestOrderCompletion(
[
{ order: 4, stepOrder: 0 },
{ order: 5, stepOrder: 0 },
],
"CanLoadMore",
"ready",
),
).toEqual({ order: "idle", loadMore: false });
expect(planOldestOrderCompletion(partialOrder, "Exhausted", 4)).toEqual({
order: "idle",
loadMore: false,
});
});

it("stops if realtime updates remove the target order", () => {
expect(
planOldestOrderCompletion([{ order: 5, stepOrder: 0 }], "CanLoadMore", 4),
).toEqual({ order: "idle", loadMore: false });
});
});

describe("dedupeMessages", () => {
it("should prefer messages from messages list when streaming messages are absent", () => {
const messages: TestMessage[] = [
Expand Down Expand Up @@ -332,3 +415,97 @@ describe("mergeUIMessages", () => {
]);
});
});

describe("split pagination boundary (issue #193)", () => {
function doc(overrides: Partial<MessageDoc>): MessageDoc {
return {
_id: `m${overrides.order}-${overrides.stepOrder}`,
_creationTime: 0,
order: 0,
stepOrder: 0,
status: "success",
threadId: "t1",
tool: false,
...overrides,
};
}

// Page starts partway through order 4: the tool call at stepOrder 0 is missing.
const splitPage = [
doc({
order: 4,
stepOrder: 1,
tool: true,
message: {
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call1",
toolName: "myTool",
output: { type: "text", value: "42" },
},
],
},
}),
doc({
order: 4,
stepOrder: 2,
message: { role: "assistant", content: "The answer is 42." },
text: "The answer is 42.",
}),
];

const anchorPage = [
doc({
order: 4,
stepOrder: 0,
tool: true,
message: {
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call1",
toolName: "myTool",
input: "question",
args: "question",
},
],
},
}),
];

it("loads follow-up pages until the split order is whole", () => {
let loaded = splitPage;
let orderToComplete: "ready" | number | "idle" = "ready";
let loads = 0;
for (let i = 0; i < 5; i++) {
const decision = planOldestOrderCompletion(
loaded,
"CanLoadMore",
orderToComplete,
);
orderToComplete = decision.order;
if (!decision.loadMore) break;
loads++;
loaded = [...anchorPage, ...loaded];
}

expect(loads).toBe(1);
expect(orderToComplete).toBe("idle");

const order4 = toUIMessages(loaded).filter((m) => m.order === 4);
expect(order4).toHaveLength(1);
expect(order4[0].stepOrder).toBe(0);
expect(order4[0].key).toBe("t1-4-0");
expect(
order4[0].parts.filter((p) => p.type === "tool-myTool"),
).toHaveLength(1);
});

it("keys a split order off the wrong row, which is why streaming never settles", () => {
const order4 = toUIMessages(splitPage).filter((m) => m.order === 4);
expect(order4[0].key).not.toBe("t1-4-0");
});
});
Loading
Loading