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,