fix(api): return assistant messages with parents before children

getThread ordered purely by created_at, which is second-granular on MySQL
and SQLite. A user turn and the assistant reply routinely land in the same
second, so their relative order was whatever the storage engine returned.
The client rebuilds the conversation tree from parent_id, so a child ahead
of its parent corrupts the thread.

Adds an id tiebreaker for a deterministic database order, then a stable
topological pass that guarantees parents precede children while preserving
sibling order. Dangling parent references are treated as roots and cycles
are appended, so no message is ever dropped.
This commit is contained in:
Thomas Camlong committed 2026-08-07 17:30:16 +02:00
1 parent a4a53af79d
commit d82db8c6bd
3 files changed
+119 -2

No files matched your search

+5 -2
View File
@@ -29,6 +29,7 @@ import {
import type { createTRPCContext } from "../trpc";
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
import { orderMessagesByParent } from "../assistant-message-order";
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { boardRouter } from "./board";
@@ -710,11 +711,13 @@ export const assistantRouter = createTRPCRouter({
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
const messages = await ctx.db.query.assistantMessages.findMany({
where: eq(assistantMessages.threadId, thread.id),
orderBy: assistantMessages.createdAt,
// `created_at` is only second-granular on MySQL and SQLite, so sibling messages written in the
// same second tie. The id tiebreaker makes the database order at least deterministic.
orderBy: [asc(assistantMessages.createdAt), asc(assistantMessages.id)],
});
return {
thread,
messages: messages.map((message) => ({
messages: orderMessagesByParent(messages).map((message) => ({
id: message.id,
parentId: message.parentId,
format: message.format,