Skip to content

Commit 98099a6

Browse files
committed
fix(chat): keep a withdrawn send in its own chat, and release stranded claims
Audit follow-ups, two of them real defects in the previous commit. A withdrawn send routed unconditionally through the cross-surface lanes. Those deliver to whatever chat is mounted next, so sending in one chat and switching to another re-sent the message into the second one. The dispatcher already drew the distinction; the idle path now draws it too — a chat-bound key is the stable chat id, so re-queueing under it both retries durably and keeps the message where the user put it. Only a chatless key, which dies with its mount, goes to the lanes. The claim release sat in `catch`, so the two paths that return a response without throwing — a rejected branch, and a missing chat — stranded an in-progress claim for its full 60s TTL, and a retry inside that window got a spurious "already sent" instead of the real error. Moved to `finally`. Also: `userMessageId` is now length-bounded, since it becomes part of a Postgres key and an oversized one would throw inside the claim; `requestId` was still empty at claim time, so both dedup logs printed a blank prefix; the provider segment said `mothership` on a handler that also serves the workflow copilot, and now says what the key identifies; `retryFailures` was dead config, only read by `executeWithIdempotency`, which this caller never invokes; the doc pointed at `billingIdempotency`, which has no consumers, and now points at the live Stripe analogue. Trimmed: `sendClaimRecorded` folded into clearing `sendClaim`, the unread `kind` discriminant dropped from a one-arm union, the single-use `claimedChatId` inlined, and the prose on all three of those cut back to what the code does not already say.
1 parent ac7a732 commit 98099a6

5 files changed

Lines changed: 147 additions & 76 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,43 @@ function renderUseChat(): {
145145
}
146146
}
147147

148+
/**
149+
* As `renderUseChat`, but bound to an existing chat rather than chatless. The
150+
* pathname has to match: the hook resets a chat-bound surface back to a fresh
151+
* pending key when it finds itself on the home route.
152+
*/
153+
function renderUseChatInChat(chatId: string): {
154+
getResult: () => ReturnType<typeof useChat>
155+
unmount: () => void
156+
} {
157+
navigationMocks.usePathname.mockReturnValue(`/workspace/ws-1/chat/${chatId}`)
158+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
159+
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
160+
const container = document.createElement('div')
161+
const root = createRoot(container)
162+
mountedRoots.push(root)
163+
let result: ReturnType<typeof useChat> | undefined
164+
165+
function Probe() {
166+
result = useChat('ws-1', chatId)
167+
return null
168+
}
169+
170+
act(() => {
171+
root.render(
172+
<QueryClientProvider client={queryClient}>{(<Probe />) as ReactNode}</QueryClientProvider>
173+
)
174+
})
175+
176+
return {
177+
getResult: () => {
178+
if (result === undefined) throw new Error('Hook result is not ready')
179+
return result
180+
},
181+
unmount: () => act(() => root.unmount()),
182+
}
183+
}
184+
148185
/**
149186
* Mounts a surface shaped like `home.tsx`: it drives `useChat` AND registers the
150187
* `mothership-send-message` listener that claims the event with
@@ -260,6 +297,7 @@ async function waitFor(predicate: () => boolean, budgetMs = 2000): Promise<void>
260297
describe('useChat remount send recovery', () => {
261298
beforeEach(() => {
262299
vi.stubGlobal('fetch', fetchStub)
300+
navigationMocks.usePathname.mockReturnValue('/workspace/ws-1/home')
263301
state.postBehavior = 'hang'
264302
state.postBodies = []
265303
mockRequestJson.mockResolvedValue({ chats: [] })
@@ -444,4 +482,31 @@ describe('useChat remount send recovery', () => {
444482
expect(state.postBodies[0].userMessageId).toBe('the-first-attempt')
445483
})
446484
})
485+
486+
/**
487+
* A withdrawn send belongs to the chat it was sent to. The cross-surface
488+
* lanes deliver to whatever chat is mounted next, so routing a chat-bound
489+
* send through them would drop the message into a different conversation —
490+
* exactly what happens if the user switches chats mid-send. Its key is the
491+
* stable chat id, so re-queueing under that key is the durable retry.
492+
*/
493+
it('re-queues a withdrawn chat-bound send instead of following the user', async () => {
494+
const { getResult, unmount } = renderUseChatInChat('chat-a')
495+
496+
await act(async () => {
497+
void getResult().sendMessage('belongs to chat-a')
498+
})
499+
await waitFor(() => state.postBodies.length === 1)
500+
501+
unmount()
502+
await waitFor(() => allQueuedMessages().length === 1)
503+
504+
const queues = useMothershipQueueStore.getState().queues
505+
expect(Object.keys(queues)).toEqual(['chat-a'])
506+
expect(queues['chat-a'][0].content).toBe('belongs to chat-a')
507+
// Reused on the retry so the server deduplicates it.
508+
expect(queues['chat-a'][0].resumeUserMessageId).toBe(state.postBodies[0].userMessageId)
509+
// Must NOT have gone to the cross-surface handoff.
510+
expect(MothershipHandoffStorage.consume('ws-1')).toBeNull()
511+
})
447512
})

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export interface SendMessageOptions {
151151
* when an unmount cleanup withdrew it — `userMessageId` is what a retry reuses
152152
* so the server deduplicates the two attempts.
153153
*/
154-
type StartSendMessageResult = boolean | { kind: 'withdrawn_by_cleanup'; userMessageId: string }
154+
type StartSendMessageResult = boolean | { userMessageId: string }
155155

156156
interface StartSendMessageOptions {
157157
/** Awaited before dispatch. Defaults to the hook's in-flight stop, if any. */
@@ -3905,7 +3905,7 @@ export function useChat(
39053905
server deduplicates it against that turn instead of billing
39063906
another one. */
39073907
rollbackOptimisticSend()
3908-
return { kind: 'withdrawn_by_cleanup', userMessageId }
3908+
return { userMessageId }
39093909
}
39103910
return consumedByTranscript
39113911
}
@@ -4031,14 +4031,29 @@ export function useChat(
40314031
}
40324032

40334033
const result = await startSendMessage(message, fileAttachments, contexts, options)
4034-
if (typeof result === 'object') {
4035-
handOffWithdrawnSend({
4036-
content: message,
4037-
fileAttachments,
4038-
contexts,
4039-
userMessageId: result.userMessageId,
4040-
})
4034+
if (typeof result !== 'object') return
4035+
4036+
/* An unmount cleanup withdrew the send. A chat-bound key is the stable
4037+
chat id, so re-queueing under the key this was sent to is the durable
4038+
retry — and keeps the message in that chat rather than following the
4039+
user into whichever one they opened next. Only a chatless surface,
4040+
whose key dies with the mount, goes to the cross-surface lanes. */
4041+
const withdrawn = {
4042+
content: message,
4043+
fileAttachments,
4044+
contexts,
4045+
userMessageId: result.userMessageId,
40414046
}
4047+
if (activeChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) {
4048+
handOffWithdrawnSend(withdrawn)
4049+
return
4050+
}
4051+
useMothershipQueueStore
4052+
.getState()
4053+
.enqueue(
4054+
activeChatKey,
4055+
createQueuedMessage(message, fileAttachments, contexts, result.userMessageId)
4056+
)
40424057
},
40434058
[workspaceId, createQueuedMessage, startSendMessage, handOffWithdrawnSend]
40444059
)
@@ -4578,6 +4593,10 @@ export function useChat(
45784593
useMothershipQueueStore.getState().remove(dispatchChatKey, msg.id)
45794594
}
45804595

4596+
/* What actually went out. `msg` is the snapshot from when the dispatch was
4597+
scheduled; the send below uses the re-read live entry, so recovery
4598+
tracks that rather than assuming the two still match. */
4599+
let dispatched = msg
45814600
const restoreQueuedMessage = (
45824601
handoff?: QueuedSendHandoffSeed,
45834602
withdrawnUserMessageId?: string
@@ -4604,15 +4623,15 @@ export function useChat(
46044623
the queue itself is the durable retry. */
46054624
if (withdrawnByCleanup && dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) {
46064625
handOffWithdrawnSend({
4607-
content: msg.content,
4608-
fileAttachments: msg.fileAttachments,
4609-
contexts: msg.contexts,
4626+
content: dispatched.content,
4627+
fileAttachments: dispatched.fileAttachments,
4628+
contexts: dispatched.contexts,
46104629
userMessageId: withdrawnUserMessageId,
46114630
})
46124631
return
46134632
}
46144633
useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, {
4615-
...msg,
4634+
...dispatched,
46164635
...(withdrawnUserMessageId ? { resumeUserMessageId: withdrawnUserMessageId } : {}),
46174636
})
46184637
}
@@ -4631,6 +4650,7 @@ export function useChat(
46314650
// Re-read live: the user may have applied an in-place edit (`replaceAt`)
46324651
// between dispatch scheduling and this send.
46334652
const liveMsg = queueAtSend[currentIndex]
4653+
dispatched = liveMsg
46344654
activeQueuedSendHandoff = options.queuedSendHandoff ?? liveMsg.queuedSendHandoff
46354655

46364656
const sendResult = await startSendMessage(

apps/sim/lib/copilot/chat/post.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ describe('handleUnifiedChatPost', () => {
148148
resetDbChainMock()
149149
atomicallyClaimChatSend.mockResolvedValue({
150150
claimed: true,
151-
normalizedKey: 'chat-send:mothership:msg-1:userId=user-1',
151+
normalizedKey: 'chat-send:user-message:msg-1:userId=user-1',
152152
storageMethod: 'database',
153153
claimToken: 'claim-1',
154154
})
@@ -758,7 +758,7 @@ describe('handleUnifiedChatPost', () => {
758758
it('answers an already-claimed send with the chat the first attempt opened', async () => {
759759
atomicallyClaimChatSend.mockResolvedValue({
760760
claimed: false,
761-
normalizedKey: 'chat-send:mothership:msg-1:userId=user-1',
761+
normalizedKey: 'chat-send:user-message:msg-1:userId=user-1',
762762
storageMethod: 'database',
763763
existingResult: { success: true, status: 'completed', result: { chatId: 'chat-first' } },
764764
})
@@ -798,7 +798,7 @@ describe('handleUnifiedChatPost', () => {
798798
})
799799
)
800800

801-
expect(atomicallyClaimChatSend).toHaveBeenCalledWith('mothership', 'msg-1', {
801+
expect(atomicallyClaimChatSend).toHaveBeenCalledWith('user-message', 'msg-1', {
802802
userId: 'user-1',
803803
})
804804
})
@@ -817,7 +817,7 @@ describe('handleUnifiedChatPost', () => {
817817
)
818818

819819
expect(storeChatSendResult).toHaveBeenCalledWith(
820-
'chat-send:mothership:msg-1:userId=user-1',
820+
'chat-send:user-message:msg-1:userId=user-1',
821821
expect.objectContaining({ result: { chatId: 'chat-1' } }),
822822
'database',
823823
'claim-1'

apps/sim/lib/copilot/chat/post.ts

Lines changed: 41 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,10 @@ const ChatContextSchema = z
260260

261261
const ChatMessageSchema = z.object({
262262
message: z.string().min(1, 'Message is required'),
263-
userMessageId: z.string().optional(),
263+
/* Bounded because it becomes part of a Postgres key in `chatSendIdempotency`;
264+
a client-supplied id longer than the btree entry limit would throw there.
265+
A generated id is 36 chars. */
266+
userMessageId: z.string().max(128).optional(),
264267
chatId: z.string().optional(),
265268
workflowId: z.string().optional(),
266269
workspaceId: z.string().optional(),
@@ -945,26 +948,22 @@ async function resolveBranch(params: {
945948
}
946949
}
947950

948-
/** Namespace-local provider segment for {@link chatSendIdempotency} keys. */
949-
const CHAT_SEND_IDEMPOTENCY_PROVIDER = 'mothership'
951+
/** Names what the key identifies: `chat-send:user-message:<id>:userId=<id>`. */
952+
const CHAT_SEND_IDEMPOTENCY_PROVIDER = 'user-message'
950953

951954
/**
952955
* Claims this send so a retry of it can be recognised.
953956
*
954-
* Fails open. Deduplication is an optimization here — it saves a duplicate chat
955-
* and a duplicate billed turn — whereas the send itself is the user's message,
956-
* and refusing it because a bookkeeping store is unreachable would take chat
957-
* down for everyone rather than degrade it. An unavailable store therefore
958-
* returns `undefined`, which sends normally with no claim to finalize.
957+
* Fails open: a missed deduplication costs a duplicate chat and turn, but
958+
* refusing the send loses the user's message. Returns `undefined` when the
959+
* store is unreachable, which sends normally with no claim to finalize.
959960
*
960-
* The key is scoped to the caller: `userMessageId` is client-supplied, so an
961-
* unscoped one would let a user probe another's sends and read back their chat
962-
* id.
961+
* The key is scoped to the caller — `userMessageId` is client-supplied, so an
962+
* unscoped one would let a user probe another's sends for their chat id.
963963
*/
964964
async function claimChatSend(
965965
userMessageId: string,
966-
userId: string,
967-
requestId: string
966+
userId: string
968967
): Promise<AtomicClaimResult | undefined> {
969968
try {
970969
return await chatSendIdempotency.atomicallyClaim(
@@ -973,20 +972,14 @@ async function claimChatSend(
973972
{ userId }
974973
)
975974
} catch (error) {
976-
logger.warn(`[${requestId}] Could not claim chat send; proceeding without deduplication`, {
975+
logger.warn('Could not claim chat send; proceeding without deduplication', {
977976
userMessageId,
978977
error: getErrorMessage(error, 'Unknown error'),
979978
})
980979
return undefined
981980
}
982981
}
983982

984-
/** Chat a previously-claimed send resolved to, when it got that far. */
985-
function claimedChatId(claim: AtomicClaimResult): string | undefined {
986-
const chatId = claim.existingResult?.result?.chatId
987-
return typeof chatId === 'string' && chatId ? chatId : undefined
988-
}
989-
990983
/**
991984
* Answers a send whose `userMessageId` was already claimed.
992985
*
@@ -997,13 +990,10 @@ function claimedChatId(claim: AtomicClaimResult): string | undefined {
997990
* first attempt got far enough to resolve one, letting a chatless client adopt
998991
* it without a stream-to-chat lookup.
999992
*/
1000-
function duplicateChatSendResponse(
1001-
claim: AtomicClaimResult,
1002-
userMessageId: string,
1003-
requestId: string
1004-
): NextResponse {
1005-
const chatId = claimedChatId(claim)
1006-
logger.info(`[${requestId}] Deduplicated a repeated chat send`, { userMessageId, chatId })
993+
function duplicateChatSendResponse(claim: AtomicClaimResult, userMessageId: string): NextResponse {
994+
const claimed = claim.existingResult?.result?.chatId
995+
const chatId = typeof claimed === 'string' && claimed ? claimed : undefined
996+
logger.info('Deduplicated a repeated chat send', { userMessageId, chatId })
1007997
return NextResponse.json(
1008998
{
1009999
error: 'This message was already sent.',
@@ -1018,13 +1008,8 @@ export async function handleUnifiedChatPost(req: NextRequest) {
10181008
let actualChatId: string | undefined
10191009
let userMessageId = ''
10201010
let chatStreamLockAcquired = false
1021-
/* Held from the moment this send is claimed until the chat it belongs to is
1022-
recorded. Released on the error path so a failed send can be retried, and
1023-
deliberately NOT released once recorded — by then the chat exists and the
1024-
user message is persisted, so a retry must resolve to it rather than open
1025-
a second chat. */
1011+
/** Cleared once the chat is recorded against it, which makes it permanent. */
10261012
let sendClaim: AtomicClaimResult | undefined
1027-
let sendClaimRecorded = false
10281013
// Started once we've parsed the body (need userMessageId to stamp as
10291014
// streamId). Every subsequent span (persistUserMessage,
10301015
// createRunSegment, the whole SSE stream, etc.) nests under this
@@ -1059,9 +1044,9 @@ export async function handleUnifiedChatPost(req: NextRequest) {
10591044
const normalizedContexts = normalizeContexts(body.contexts) ?? []
10601045
userMessageId = body.userMessageId || generateId()
10611046

1062-
sendClaim = await claimChatSend(userMessageId, authenticatedUserId, requestId)
1047+
sendClaim = await claimChatSend(userMessageId, authenticatedUserId)
10631048
if (sendClaim?.claimed === false) {
1064-
return duplicateChatSendResponse(sendClaim, userMessageId, requestId)
1049+
return duplicateChatSendResponse(sendClaim, userMessageId)
10651050
}
10661051

10671052
otelRoot = startCopilotOtelRoot({
@@ -1156,13 +1141,12 @@ export async function handleUnifiedChatPost(req: NextRequest) {
11561141
}
11571142
}
11581143

1159-
/* Record the chat against this send as soon as it is known, the earliest
1160-
a retry can be answered with somewhere to go. From here the claim is
1161-
permanent: the user message lands moments later, so a retry must
1162-
resolve to this chat rather than open another. Failing to record only
1163-
costs deduplication, so it must not fail the send. */
1144+
/* Record the chat as soon as it is known — the earliest a retry can be
1145+
answered with somewhere to go. The claim becomes permanent here: the
1146+
user message lands moments later, so a retry must resolve to this chat
1147+
rather than open another. */
11641148
if (sendClaim?.claimToken && actualChatId) {
1165-
sendClaimRecorded = await chatSendIdempotency
1149+
const recorded = await chatSendIdempotency
11661150
.storeResult(
11671151
sendClaim.normalizedKey,
11681152
{ success: true, status: 'completed', result: { chatId: actualChatId } },
@@ -1176,6 +1160,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
11761160
})
11771161
return false
11781162
})
1163+
if (recorded) sendClaim = undefined
11791164
}
11801165

11811166
if (chatIsNew && actualChatId && body.resourceAttachments?.length) {
@@ -1482,19 +1467,6 @@ export async function handleUnifiedChatPost(req: NextRequest) {
14821467
if (chatStreamLockAcquired && actualChatId && userMessageId) {
14831468
await releasePendingChatStream(actualChatId, userMessageId)
14841469
}
1485-
// Nothing was recorded against this send, so let a retry of it through
1486-
// rather than deduplicating against a chat that was never opened. A failed
1487-
// release just leaves the claim to expire on its own short in-progress TTL.
1488-
if (sendClaim?.claimToken && !sendClaimRecorded) {
1489-
await chatSendIdempotency
1490-
.release(sendClaim.normalizedKey, sendClaim.storageMethod, sendClaim.claimToken)
1491-
.catch((releaseError) => {
1492-
logger.warn(`[${requestId}] Could not release the claim for a failed send`, {
1493-
userMessageId,
1494-
error: getErrorMessage(releaseError, 'Unknown error'),
1495-
})
1496-
})
1497-
}
14981470
otelRoot?.finish('error', error)
14991471

15001472
if (isZodError(error)) {
@@ -1524,5 +1496,20 @@ export async function handleUnifiedChatPost(req: NextRequest) {
15241496
},
15251497
{ status: 500 }
15261498
)
1499+
} finally {
1500+
/* A claim still held here never recorded a chat — the send threw, or
1501+
returned early on a rejected branch or a missing chat. Release it so a
1502+
retry is treated as new rather than deduplicated against a chat that was
1503+
never opened. Must be `finally`: those early returns skip `catch`. */
1504+
if (sendClaim?.claimToken) {
1505+
await chatSendIdempotency
1506+
.release(sendClaim.normalizedKey, sendClaim.storageMethod, sendClaim.claimToken)
1507+
.catch((releaseError) => {
1508+
logger.warn('Could not release the claim for an unfinished send', {
1509+
userMessageId,
1510+
error: getErrorMessage(releaseError, 'Unknown error'),
1511+
})
1512+
})
1513+
}
15271514
}
15281515
}

0 commit comments

Comments
 (0)