From 2e59f3c93f6a9742dcfadd143be1e14e9ee34675 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 2 Sep 2026 13:45:30 -0700 Subject: [PATCH] fix(copilot): stop async chat auto-titling from clobbering an explicit rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat title generation is fired at turn start and resolves asynchronously, but its write was unconditional (`WHERE id = chatId`), re-checking nothing. The guard that decides whether to generate reads a snapshot taken at request start, so a title generated from that snapshot could land long after the user renamed the chat and overwrite the rename — a classic check-then-write race. A user who renames a chat shortly after their first message could silently lose the rename. Make the write conditional on the title still being unset, so the generated title fills an empty slot and can never overwrite an explicit one, and skip the follow-up notification when the write lost the race (so we never announce a title the row no longer holds). Both auto-title call sites are fixed: the interactive turn path (lib/copilot/request/lifecycle/start.ts) and the inbox task path (lib/mothership/inbox/executor.ts), which had the identical unconditional write. --- apps/sim/lib/copilot/request/lifecycle/start.ts | 14 ++++++++++++-- apps/sim/lib/mothership/inbox/executor.ts | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/request/lifecycle/start.ts b/apps/sim/lib/copilot/request/lifecycle/start.ts index b3a2dd7d210..2d7943f74ad 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { assertBillingAttributionSnapshot, type BillingAttributionSnapshot, @@ -489,7 +489,17 @@ function fireTitleGeneration(params: { }) .then(async (title) => { if (!title) return - await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId)) + // Only stamp the generated title while the chat has none. Title + // generation is fired at turn start and resolves asynchronously, so a + // user could rename the chat in the meantime; the `isNull` guard makes + // the write lose that race instead of clobbering the explicit rename. + const stamped = await db + .update(copilotChats) + .set({ title }) + .where(and(eq(copilotChats.id, chatId), isNull(copilotChats.title))) + .returning({ id: copilotChats.id }) + // The rename won — do not announce a title the row no longer holds. + if (stamped.length === 0) return await publisher.publish({ type: MothershipStreamV1EventType.session, payload: { kind: MothershipStreamV1SessionKind.title, title }, diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 345c5f1c992..56f0c92e74f 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -2,7 +2,7 @@ import { copilotChats, db, mothershipInboxTask, user, workspace } from '@sim/db' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' import { getActivelyBannedUserIds, isEmailBlocked } from '@/lib/auth/ban' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' @@ -167,7 +167,17 @@ export async function executeInboxTask(taskId: string): Promise { }) .then(async (title) => { if (title && chatId) { - await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId)) + // Only stamp the generated title while the chat has none. This + // resolves asynchronously, so a user could rename the chat in the + // meantime; the `isNull` guard makes the write lose that race + // instead of clobbering the explicit rename. + const stamped = await db + .update(copilotChats) + .set({ title }) + .where(and(eq(copilotChats.id, chatId), isNull(copilotChats.title))) + .returning({ id: copilotChats.id }) + // The rename won — do not announce a title the row no longer holds. + if (stamped.length === 0) return chatPubSub?.publishStatusChanged({ workspaceId: ws.id, chatId,