Skip to content

Commit 2e59f3c

Browse files
committed
fix(copilot): stop async chat auto-titling from clobbering an explicit rename
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.
1 parent d218534 commit 2e59f3c

2 files changed

Lines changed: 24 additions & 4 deletions

File tree

apps/sim/lib/copilot/request/lifecycle/start.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { db } from '@sim/db'
33
import { copilotChats } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { getErrorMessage } from '@sim/utils/errors'
6-
import { eq } from 'drizzle-orm'
6+
import { and, eq, isNull } from 'drizzle-orm'
77
import {
88
assertBillingAttributionSnapshot,
99
type BillingAttributionSnapshot,
@@ -489,7 +489,17 @@ function fireTitleGeneration(params: {
489489
})
490490
.then(async (title) => {
491491
if (!title) return
492-
await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId))
492+
// Only stamp the generated title while the chat has none. Title
493+
// generation is fired at turn start and resolves asynchronously, so a
494+
// user could rename the chat in the meantime; the `isNull` guard makes
495+
// the write lose that race instead of clobbering the explicit rename.
496+
const stamped = await db
497+
.update(copilotChats)
498+
.set({ title })
499+
.where(and(eq(copilotChats.id, chatId), isNull(copilotChats.title)))
500+
.returning({ id: copilotChats.id })
501+
// The rename won — do not announce a title the row no longer holds.
502+
if (stamped.length === 0) return
493503
await publisher.publish({
494504
type: MothershipStreamV1EventType.session,
495505
payload: { kind: MothershipStreamV1SessionKind.title, title },

apps/sim/lib/mothership/inbox/executor.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { copilotChats, db, mothershipInboxTask, user, workspace } from '@sim/db'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage } from '@sim/utils/errors'
44
import { generateId } from '@sim/utils/id'
5-
import { and, eq, sql } from 'drizzle-orm'
5+
import { and, eq, isNull, sql } from 'drizzle-orm'
66
import { getActivelyBannedUserIds, isEmailBlocked } from '@/lib/auth/ban'
77
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
88
import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle'
@@ -167,7 +167,17 @@ export async function executeInboxTask(taskId: string): Promise<void> {
167167
})
168168
.then(async (title) => {
169169
if (title && chatId) {
170-
await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId))
170+
// Only stamp the generated title while the chat has none. This
171+
// resolves asynchronously, so a user could rename the chat in the
172+
// meantime; the `isNull` guard makes the write lose that race
173+
// instead of clobbering the explicit rename.
174+
const stamped = await db
175+
.update(copilotChats)
176+
.set({ title })
177+
.where(and(eq(copilotChats.id, chatId), isNull(copilotChats.title)))
178+
.returning({ id: copilotChats.id })
179+
// The rename won — do not announce a title the row no longer holds.
180+
if (stamped.length === 0) return
171181
chatPubSub?.publishStatusChanged({
172182
workspaceId: ws.id,
173183
chatId,

0 commit comments

Comments
 (0)