From 04147f7401a513033fce82258b7ee8a947acd70e Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:13:20 -0700 Subject: [PATCH 01/12] fix(chat): stop losing sends aborted during mount-settling --- .../home/hooks/use-chat.mount-send.test.tsx | 183 ++++++++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 41 +++- 2 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx new file mode 100644 index 00000000000..e12d0320328 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -0,0 +1,183 @@ +/** + * @vitest-environment jsdom + * + * Regression tests for the mount-settling send loss: a send started on a + * fresh chat surface used to be silently dropped when React ran the unmount + * cleanup mid-flight (a Suspense hide/reveal cycles every effect shortly + * after Home mounts), aborting the fetch before it dispatched. The fix routes + * idle sends through the durable message queue and restores the queued entry + * when the cleanup abort strikes before the server received the request. + */ +import { act, type ReactNode } from 'react' +import { sleep } from '@sim/utils/helpers' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson, navigationMocks } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), + navigationMocks: { + usePathname: vi.fn(() => '/workspace/ws-1/home'), + useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() })), + useSearchParams: vi.fn(() => new URLSearchParams()), + }, +})) + +vi.mock('next/navigation', () => navigationMocks) + +vi.mock('@/lib/api/client/request', async (importOriginal) => ({ + ...(await importOriginal()), + requestJson: mockRequestJson, +})) + +import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' +import { useMothershipQueueStore } from '@/stores/mothership-queue/store' + +interface NetworkState { + /** How the chat POST behaves for the next call. */ + postBehavior: 'hang' | 'accept' + postCalls: number +} + +const state: NetworkState = { postBehavior: 'hang', postCalls: 0 } + +/** An SSE response whose stream ends immediately without a terminal event. */ +function emptySseResponse(): Response { + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }) +} + +function abortError(): Error { + const error = new Error('Aborted') + error.name = 'AbortError' + return error +} + +async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = String(input instanceof Request ? input.url : input) + + if (url.includes('/api/mothership/chat') && init?.method === 'POST') { + state.postCalls++ + if (state.postBehavior === 'accept') return emptySseResponse() + return new Promise((_, reject) => { + const signal = init?.signal + if (!signal) return + if (signal.aborted) { + reject(abortError()) + return + } + signal.addEventListener('abort', () => reject(abortError()), { once: true }) + }) + } + + return new Response(JSON.stringify({ error: 'not found' }), { status: 404 }) +} + +const mountedRoots: Root[] = [] +let queryClient: QueryClient + +function renderUseChat(): { + getResult: () => ReturnType + unmount: () => void +} { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const container = document.createElement('div') + const root = createRoot(container) + mountedRoots.push(root) + let result: ReturnType | undefined + + function Probe() { + result = useChat('ws-1', undefined) + return null + } + + act(() => { + root.render( + {() as ReactNode} + ) + }) + + return { + getResult: () => { + if (result === undefined) throw new Error('Hook result is not ready') + return result + }, + unmount: () => act(() => root.unmount()), + } +} + +/** Every queued message across all chat keys, flattened. */ +function allQueuedMessages() { + return Object.values(useMothershipQueueStore.getState().queues).flat() +} + +async function waitFor(predicate: () => boolean, budgetMs = 2000): Promise { + const deadline = Date.now() + budgetMs + while (!predicate()) { + if (Date.now() > deadline) throw new Error('waitFor timed out') + await act(async () => { + await sleep(10) + }) + } +} + +describe('useChat mount-settling send recovery', () => { + beforeEach(() => { + vi.stubGlobal('fetch', fetchStub) + state.postBehavior = 'hang' + state.postCalls = 0 + mockRequestJson.mockResolvedValue({ chats: [] }) + useMothershipQueueStore.setState({ queues: {}, editing: {} }) + window.sessionStorage.clear() + }) + + afterEach(() => { + for (const root of mountedRoots.splice(0)) { + act(() => root.unmount()) + } + queryClient?.clear() + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('restores a send the unmount cleanup aborted before the server received it', async () => { + const { getResult, unmount } = renderUseChat() + + await act(async () => { + void getResult().sendMessage('hello from the palette') + }) + await waitFor(() => state.postCalls === 1) + + // The dispatch claimed the queue head when the optimistic send applied. + expect(allQueuedMessages()).toHaveLength(0) + + // The cleanup abort (same code path a Suspense hide/reveal runs during + // mount-settling) fires while the POST is still awaiting the server. + unmount() + await waitFor(() => allQueuedMessages().length === 1) + + expect(allQueuedMessages()[0].content).toBe('hello from the palette') + }) + + it('does not re-queue a send the server already received', async () => { + state.postBehavior = 'accept' + const { getResult, unmount } = renderUseChat() + + await act(async () => { + void getResult().sendMessage('already accepted') + }) + await waitFor(() => state.postCalls === 1) + + unmount() + await act(async () => { + await sleep(50) + }) + + expect(allQueuedMessages()).toHaveLength(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index e3ce3327301..ee3f8f6f961 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1343,6 +1343,12 @@ export function useChat( const queueDispatchActionsRef = useRef([]) const queueDispatchTaskRef = useRef | null>(null) const queueDispatchEpochRef = useRef(0) + /** + * Set when the in-flight dispatch was killed by the unmount cleanup before + * reaching the server. Lets the restore path re-queue the message across the + * epoch bump that same cleanup performs. + */ + const restorableCleanupAbortRef = useRef(false) const queueDispatchLoopRef = useRef<() => Promise>(async () => {}) const enqueueQueueDispatchRef = useRef<(action: QueueDispatchActionInput) => Promise>( async () => {} @@ -3465,6 +3471,8 @@ export function useChat( : undefined let consumedByTranscript = false + let sendReachedServer = false + let sendAbortSignal: AbortSignal | null = null setError(null) setTransportStreaming() @@ -3697,6 +3705,7 @@ export function useChat( } const abortController = new AbortController() abortControllerRef.current = abortController + sendAbortSignal = abortController.signal const resourceAttachments = buildResourceAttachments( resourcesRef.current, @@ -3725,6 +3734,7 @@ export function useChat( }), signal: abortController.signal, }) + sendReachedServer = true // Capture for propagation on side-channel calls + non-React // tool-completion callbacks (via trace-context singleton). @@ -3823,7 +3833,19 @@ export function useChat( } } } catch (err) { - if (err instanceof Error && err.name === 'AbortError') return consumedByTranscript + if (err instanceof Error && err.name === 'AbortError') { + if (sendAbortSignal?.reason === 'unmount:client_cleanup' && !sendReachedServer) { + /* The mount-settling effect cycle (Suspense hide/reveal) ran the + unmount cleanup while this send was still pre-dispatch. Nothing + reached the server, so the send is fully recoverable: withdraw + the optimistic pair and report not-consumed so the queued entry + is restored and re-dispatched when effects re-run. */ + rollbackOptimisticSend() + restorableCleanupAbortRef.current = true + return false + } + return consumedByTranscript + } if (isStreamSchemaValidationError(err)) { setError(err.message) if (gen !== undefined && streamGenRef.current === gen) { @@ -3912,9 +3934,15 @@ export function useChat( return } - await startSendMessage(message, fileAttachments, contexts) + /* Even an idle-path send goes through the durable queue: a direct + startSendMessage has no backing entry, so the cleanup abort that runs + when a Suspense hide/reveal cycles effects during mount-settling would + silently drop it. The dispatch loop claims the head immediately, so + the message never renders as queued. */ + queueStore.enqueue(activeChatKey, createQueuedMessage(message, fileAttachments, contexts)) + void enqueueQueueDispatchRef.current({ type: 'send_head' }) }, - [workspaceId, startSendMessage, createQueuedMessage] + [workspaceId, createQueuedMessage] ) useEffect(() => { if (typeof window === 'undefined') return @@ -4461,7 +4489,10 @@ export function useChat( clearQueuedSendHandoffState(msg.id) } clearQueuedSendHandoffClaim(msg.id) - if (!removedFromQueue || options.epoch !== queueDispatchEpochRef.current) { + if (!removedFromQueue) { + return + } + if (options.epoch !== queueDispatchEpochRef.current && !restorableCleanupAbortRef.current) { return } // If the user explicitly removed this message during dispatch, honor @@ -4487,6 +4518,7 @@ export function useChat( // between dispatch scheduling and this send. const liveMsg = queueAtSend[currentIndex] activeQueuedSendHandoff = options.queuedSendHandoff ?? liveMsg.queuedSendHandoff + restorableCleanupAbortRef.current = false const consumed = await startSendMessage( liveMsg.content, liveMsg.fileAttachments, @@ -4502,6 +4534,7 @@ export function useChat( } catch { restoreQueuedMessage(activeQueuedSendHandoff) } finally { + restorableCleanupAbortRef.current = false setDispatchingHeadId((current) => (current === msg.id ? null : current)) queuedMessageDispatchIdsRef.current.delete(msg.id) userRemovedDuringDispatchRef.current.delete(msg.id) From b840a3a15335b877c4cb4d8a36d4095baec9eb65 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:30:14 -0700 Subject: [PATCH 02/12] fix(chat): detect aborts by signal state, not error identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch rejects with the RAW abort reason when its signal carries one — abort('unmount:client_cleanup') surfaces as a plain string, so every err.name === 'AbortError' check missed it and the restore path never ran (verified live). The test stub now rejects with the raw reason like real fetch, which turns this gap red. --- .../home/hooks/use-chat.mount-send.test.tsx | 12 ++++-------- .../workspace/[workspaceId]/home/hooks/use-chat.ts | 7 ++++++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index e12d0320328..2b3bef325e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -51,12 +51,6 @@ function emptySseResponse(): Response { return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }) } -function abortError(): Error { - const error = new Error('Aborted') - error.name = 'AbortError' - return error -} - async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise { const url = String(input instanceof Request ? input.url : input) @@ -66,11 +60,13 @@ async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise< return new Promise((_, reject) => { const signal = init?.signal if (!signal) return + // Real fetch rejects with the RAW abort reason (a string here), not an + // AbortError — the regression this suite guards depends on that shape. if (signal.aborted) { - reject(abortError()) + reject(signal.reason) return } - signal.addEventListener('abort', () => reject(abortError()), { once: true }) + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) }) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index ee3f8f6f961..8124a1369da 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -3833,7 +3833,12 @@ export function useChat( } } } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { + /* fetch rejects with the RAW abort reason (here a plain string) when + its signal was aborted with abort(reason) — an `err.name` check alone + misses those, so abort detection also consults the signal itself. */ + const sendWasAborted = + (err instanceof Error && err.name === 'AbortError') || sendAbortSignal?.aborted === true + if (sendWasAborted) { if (sendAbortSignal?.reason === 'unmount:client_cleanup' && !sendReachedServer) { /* The mount-settling effect cycle (Suspense hide/reveal) ran the unmount cleanup while this send was still pre-dispatch. Nothing From cd23af8b994b284a0b10484c8393d9502fc85a0a Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:36:28 -0700 Subject: [PATCH 03/12] fix(chat): hand an aborted chatless send to the next mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mount-settling cycle is a full remount — the pending chat key is regenerated per instance, so restoring the aborted send into the dead instance's queue orphaned it (verified live). A chatless send now re-persists as a one-shot MothershipHandoffStorage handoff the next mount's consumer re-sends; chat-bound sends keep the queue restore. --- .../home/hooks/use-chat.mount-send.test.tsx | 17 +++++++++---- .../[workspaceId]/home/hooks/use-chat.ts | 24 ++++++++++++++++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index 2b3bef325e8..44c71a8c012 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -30,6 +30,7 @@ vi.mock('@/lib/api/client/request', async (importOriginal) => ({ requestJson: mockRequestJson, })) +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' @@ -130,6 +131,7 @@ describe('useChat mount-settling send recovery', () => { mockRequestJson.mockResolvedValue({ chats: [] }) useMothershipQueueStore.setState({ queues: {}, editing: {} }) window.sessionStorage.clear() + window.localStorage.clear() }) afterEach(() => { @@ -141,7 +143,7 @@ describe('useChat mount-settling send recovery', () => { vi.clearAllMocks() }) - it('restores a send the unmount cleanup aborted before the server received it', async () => { + it('re-persists an aborted chatless send as a handoff for the next mount', async () => { const { getResult, unmount } = renderUseChat() await act(async () => { @@ -152,12 +154,16 @@ describe('useChat mount-settling send recovery', () => { // The dispatch claimed the queue head when the optimistic send applied. expect(allQueuedMessages()).toHaveLength(0) - // The cleanup abort (same code path a Suspense hide/reveal runs during - // mount-settling) fires while the POST is still awaiting the server. + // The cleanup abort (the same code path the mount-settling remount runs) + // fires while the POST is still awaiting the server. A chatless surface + // regenerates its queue key per mount, so recovery re-persists the send + // as a one-shot handoff for the next mount's consumer instead of + // restoring the dead instance's queue. unmount() - await waitFor(() => allQueuedMessages().length === 1) + await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null) - expect(allQueuedMessages()[0].content).toBe('hello from the palette') + expect(allQueuedMessages()).toHaveLength(0) + expect(MothershipHandoffStorage.consume('ws-1')?.message).toBe('hello from the palette') }) it('does not re-queue a send the server already received', async () => { @@ -175,5 +181,6 @@ describe('useChat mount-settling send recovery', () => { }) expect(allQueuedMessages()).toHaveLength(0) + expect(MothershipHandoffStorage.consume('ws-1')).toBeNull() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 8124a1369da..0720ae93db8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -82,6 +82,7 @@ import { executeTerminalToolOnClient } from '@/lib/copilot/tools/client/terminal import { setCurrentChatTraceparent } from '@/lib/copilot/tools/client/trace-context' import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { readSSELines } from '@/lib/core/utils/sse' import { getDesktopBridge, getDesktopChatCapabilities } from '@/lib/desktop' import { @@ -4505,6 +4506,27 @@ export function useChat( if (userRemovedDuringDispatchRef.current.delete(msg.id)) { return } + /* A pending (chatless) surface regenerates its chat key per mount, and + the cleanup that aborted this send belongs to a full remount — a + queue restore would orphan the message under the dead instance's + key. Re-persist it as a one-shot handoff instead: the next mount's + consumer re-sends it. Chat-bound sends keep the queue restore (their + key is the stable chat id). Attachment payloads exceed what the + handoff carries, so they fall back to the queue restore. */ + if ( + restorableCleanupAbortRef.current && + dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) && + !msg.fileAttachments?.length + ) { + MothershipHandoffStorage.store( + { + message: msg.content, + ...(msg.contexts?.length ? { contexts: msg.contexts } : {}), + }, + workspaceId + ) + return + } useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, msg) } @@ -4545,7 +4567,7 @@ export function useChat( userRemovedDuringDispatchRef.current.delete(msg.id) } }, - [startSendMessage] + [startSendMessage, workspaceId] ) const runQueueDispatchLoop = useCallback(async () => { From ed4ea7cc3dbb726f64440c536b8e2b426b1338bb Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:39:02 -0700 Subject: [PATCH 04/12] fix(chat): deliver an aborted chatless send to the live replacement surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settling remount's consumer checks handoff storage before the restore microtask re-persists it, so the stored handoff sat unread until a navigation. The replacement surface's send listener IS registered by restore time — deliver the message directly through the claimable send event, keeping the stored handoff as the no-surface fallback. --- .../home/hooks/use-chat.mount-send.test.tsx | 25 +++++++++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 21 ++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index 44c71a8c012..06e83b45559 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -143,6 +143,31 @@ describe('useChat mount-settling send recovery', () => { vi.clearAllMocks() }) + it('delivers an aborted chatless send directly to a live replacement surface', async () => { + const received: string[] = [] + const claim = (event: Event) => { + received.push((event as CustomEvent<{ message: string }>).detail.message) + event.preventDefault() + } + window.addEventListener('mothership-send-message', claim) + + try { + const { getResult, unmount } = renderUseChat() + await act(async () => { + void getResult().sendMessage('hello from the palette') + }) + await waitFor(() => state.postCalls === 1) + + unmount() + await waitFor(() => received.length === 1) + + expect(received).toEqual(['hello from the palette']) + expect(window.localStorage.getItem('sim_mothership_handoff')).toBeNull() + } finally { + window.removeEventListener('mothership-send-message', claim) + } + }) + it('re-persists an aborted chatless send as a handoff for the next mount', async () => { const { getResult, unmount } = renderUseChat() diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 0720ae93db8..a3fe0767a90 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -92,6 +92,7 @@ import { migrateDesktopChatScopes, PENDING_CHAT_KEY_PREFIX, } from '@/lib/desktop/chat-scope' +import { sendMothershipMessage } from '@/lib/mothership/events' import { initTerminalTransport } from '@/lib/terminal/transport' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { useFilePreviewController } from '@/app/workspace/[workspaceId]/home/hooks/preview' @@ -4518,13 +4519,19 @@ export function useChat( dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) && !msg.fileAttachments?.length ) { - MothershipHandoffStorage.store( - { - message: msg.content, - ...(msg.contexts?.length ? { contexts: msg.contexts } : {}), - }, - workspaceId - ) + /* The settling remount has already run its mount effects by the time + this microtask executes, so the replacement surface's send listener + is live — deliver directly. The stored-handoff fallback covers a + real navigation away, where the next mount's consumer picks it up. */ + if (!sendMothershipMessage(msg.content, msg.contexts)) { + MothershipHandoffStorage.store( + { + message: msg.content, + ...(msg.contexts?.length ? { contexts: msg.contexts } : {}), + }, + workspaceId + ) + } return } useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, msg) From d078e823dacb3f1eb423099539e9f6513097c8a8 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:55:58 -0700 Subject: [PATCH 05/12] refactor(chat): thread the recoverable-abort outcome through the send result Replaces the restorableCleanupAbortRef reset choreography with a widened startSendMessage return ('recoverable_cleanup_abort'), so the restore decision is ordinary data flow and the second caller cannot leave a stale flag behind. --- .../[workspaceId]/home/hooks/use-chat.ts | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index a3fe0767a90..a46f5c64300 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1345,12 +1345,6 @@ export function useChat( const queueDispatchActionsRef = useRef([]) const queueDispatchTaskRef = useRef | null>(null) const queueDispatchEpochRef = useRef(0) - /** - * Set when the in-flight dispatch was killed by the unmount cleanup before - * reaching the server. Lets the restore path re-queue the message across the - * epoch bump that same cleanup performs. - */ - const restorableCleanupAbortRef = useRef(false) const queueDispatchLoopRef = useRef<() => Promise>(async () => {}) const enqueueQueueDispatchRef = useRef<(action: QueueDispatchActionInput) => Promise>( async () => {} @@ -3842,14 +3836,12 @@ export function useChat( (err instanceof Error && err.name === 'AbortError') || sendAbortSignal?.aborted === true if (sendWasAborted) { if (sendAbortSignal?.reason === 'unmount:client_cleanup' && !sendReachedServer) { - /* The mount-settling effect cycle (Suspense hide/reveal) ran the - unmount cleanup while this send was still pre-dispatch. Nothing - reached the server, so the send is fully recoverable: withdraw - the optimistic pair and report not-consumed so the queued entry - is restored and re-dispatched when effects re-run. */ + /* The mount-settling remount ran the unmount cleanup while this + send was still pre-dispatch. Nothing reached the server, so the + send is fully recoverable: withdraw the optimistic pair and + report the distinct outcome so the dispatcher redelivers it. */ rollbackOptimisticSend() - restorableCleanupAbortRef.current = true - return false + return 'recoverable_cleanup_abort' } return consumedByTranscript } @@ -4491,7 +4483,10 @@ export function useChat( useMothershipQueueStore.getState().remove(dispatchChatKey, msg.id) } - const restoreQueuedMessage = (handoff?: QueuedSendHandoffSeed) => { + const restoreQueuedMessage = ( + handoff?: QueuedSendHandoffSeed, + recoverableCleanupAbort = false + ) => { if (!handoff) { clearQueuedSendHandoffState(msg.id) } @@ -4499,7 +4494,7 @@ export function useChat( if (!removedFromQueue) { return } - if (options.epoch !== queueDispatchEpochRef.current && !restorableCleanupAbortRef.current) { + if (options.epoch !== queueDispatchEpochRef.current && !recoverableCleanupAbort) { return } // If the user explicitly removed this message during dispatch, honor @@ -4515,7 +4510,7 @@ export function useChat( key is the stable chat id). Attachment payloads exceed what the handoff carries, so they fall back to the queue restore. */ if ( - restorableCleanupAbortRef.current && + recoverableCleanupAbort && dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) && !msg.fileAttachments?.length ) { @@ -4552,8 +4547,7 @@ export function useChat( // between dispatch scheduling and this send. const liveMsg = queueAtSend[currentIndex] activeQueuedSendHandoff = options.queuedSendHandoff ?? liveMsg.queuedSendHandoff - restorableCleanupAbortRef.current = false - const consumed = await startSendMessage( + const sendResult = await startSendMessage( liveMsg.content, liveMsg.fileAttachments, liveMsg.contexts, @@ -4562,13 +4556,12 @@ export function useChat( activeQueuedSendHandoff ) - if (!consumed) { - restoreQueuedMessage(activeQueuedSendHandoff) + if (sendResult !== true) { + restoreQueuedMessage(activeQueuedSendHandoff, sendResult === 'recoverable_cleanup_abort') } } catch { restoreQueuedMessage(activeQueuedSendHandoff) } finally { - restorableCleanupAbortRef.current = false setDispatchingHeadId((current) => (current === msg.id ? null : current)) queuedMessageDispatchIdsRef.current.delete(msg.id) userRemovedDuringDispatchRef.current.delete(msg.id) From 81716ffd131e50de4686c09b338bf4d9b28ccda1 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:34:10 -0700 Subject: [PATCH 06/12] fix(chat): carry attachments through the cross-mount send handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recoverable-abort delivery excluded attachment-bearing sends, so they restored under the dead instance's pending key and were silently lost. The claimable send event now carries fileAttachments end to end (dispatcher, home listener, restore path); only the storage fallback — whose shape cannot hold attachments — still queue-restores them. --- .../app/workspace/[workspaceId]/home/home.tsx | 2 +- .../home/hooks/use-chat.mount-send.test.tsx | 17 ++++++++--- .../[workspaceId]/home/hooks/use-chat.ts | 28 +++++++++---------- apps/sim/lib/mothership/events.ts | 10 ++++++- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 492b5659464..d6ed5908650 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -341,7 +341,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const detail = (e as CustomEvent).detail if (!detail?.message) return e.preventDefault() - sendMessage(detail.message, undefined, detail.contexts) + sendMessage(detail.message, detail.fileAttachments, detail.contexts) } window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index 06e83b45559..d950b238ac1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -144,9 +144,17 @@ describe('useChat mount-settling send recovery', () => { }) it('delivers an aborted chatless send directly to a live replacement surface', async () => { - const received: string[] = [] + const attachment = { + id: 'file-1', + key: 'uploads/file-1', + filename: 'notes.txt', + media_type: 'text/plain', + size: 12, + } + const received: Array<{ message: string; fileAttachments?: unknown[] }> = [] const claim = (event: Event) => { - received.push((event as CustomEvent<{ message: string }>).detail.message) + const detail = (event as CustomEvent<{ message: string; fileAttachments?: unknown[] }>).detail + received.push(detail) event.preventDefault() } window.addEventListener('mothership-send-message', claim) @@ -154,14 +162,15 @@ describe('useChat mount-settling send recovery', () => { try { const { getResult, unmount } = renderUseChat() await act(async () => { - void getResult().sendMessage('hello from the palette') + void getResult().sendMessage('hello from the palette', [attachment]) }) await waitFor(() => state.postCalls === 1) unmount() await waitFor(() => received.length === 1) - expect(received).toEqual(['hello from the palette']) + expect(received[0].message).toBe('hello from the palette') + expect(received[0].fileAttachments).toEqual([attachment]) expect(window.localStorage.getItem('sim_mothership_handoff')).toBeNull() } finally { window.removeEventListener('mothership-send-message', claim) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index a46f5c64300..f6a06efe61b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -4505,20 +4505,18 @@ export function useChat( /* A pending (chatless) surface regenerates its chat key per mount, and the cleanup that aborted this send belongs to a full remount — a queue restore would orphan the message under the dead instance's - key. Re-persist it as a one-shot handoff instead: the next mount's - consumer re-sends it. Chat-bound sends keep the queue restore (their - key is the stable chat id). Attachment payloads exceed what the - handoff carries, so they fall back to the queue restore. */ - if ( - recoverableCleanupAbort && - dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) && - !msg.fileAttachments?.length - ) { - /* The settling remount has already run its mount effects by the time - this microtask executes, so the replacement surface's send listener - is live — deliver directly. The stored-handoff fallback covers a - real navigation away, where the next mount's consumer picks it up. */ - if (!sendMothershipMessage(msg.content, msg.contexts)) { + key. Deliver to the replacement surface instead: its send listener + is live by the time this microtask executes, and the event carries + attachments. When nothing claims it (a real navigation away), a + one-shot handoff covers attachment-less sends for the next mount; + attachment payloads exceed what the handoff carries and fall back to + the queue restore. Chat-bound sends always keep the queue restore + (their key is the stable chat id). */ + if (recoverableCleanupAbort && dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) { + if (sendMothershipMessage(msg.content, msg.contexts, msg.fileAttachments)) { + return + } + if (!msg.fileAttachments?.length) { MothershipHandoffStorage.store( { message: msg.content, @@ -4526,8 +4524,8 @@ export function useChat( }, workspaceId ) + return } - return } useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, msg) } diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts index d376ee0080f..12679bf3d71 100644 --- a/apps/sim/lib/mothership/events.ts +++ b/apps/sim/lib/mothership/events.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' import type { ChatContext } from '@/stores/panel' const logger = createLogger('MothershipEvents') @@ -24,6 +25,8 @@ export interface MothershipSendMessageDetail { message: string /** Structured contexts to attach — e.g. a `logs` mention tagging a run. */ contexts?: ChatContext[] + /** Already-uploaded attachments riding along with the message. */ + fileAttachments?: FileAttachmentForApi[] } /** @@ -35,7 +38,11 @@ export interface MothershipSendMessageDetail { * was listening — callers that can fall back (e.g. cross-route navigation) use * this to decide whether to persist a handoff instead. */ -export function sendMothershipMessage(message: string, contexts?: ChatContext[]): boolean { +export function sendMothershipMessage( + message: string, + contexts?: ChatContext[], + fileAttachments?: FileAttachmentForApi[] +): boolean { const trimmed = message.trim() if (!trimmed) { logger.warn('sendMothershipMessage called with empty message') @@ -44,6 +51,7 @@ export function sendMothershipMessage(message: string, contexts?: ChatContext[]) const consumed = dispatchClaimable(MOTHERSHIP_SEND_MESSAGE_EVENT, { message: trimmed, contexts, + fileAttachments, }) logger.info('Dispatched mothership message event', { messageLength: trimmed.length, consumed }) return consumed From 45357cb851f5cef133d79b71cc2516758ac72c60 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:42:08 -0700 Subject: [PATCH 07/12] fix(panel): forward event attachments to the copilot send --- .../[workspaceId]/w/[workflowId]/components/panel/panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 57a97042c96..510d62579f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -489,7 +489,7 @@ export const Panel = memo(function Panel() { if (!detail?.message) return e.preventDefault() setActiveTab('copilot') - copilotSendMessage(detail.message, undefined, detail.contexts) + copilotSendMessage(detail.message, detail.fileAttachments, detail.contexts) } window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) From 3e677696cda920e000ef7c404fcbaf32660b8a52 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:48:57 -0700 Subject: [PATCH 08/12] fix(chat): carry attachments through the stored handoff lane too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unclaimed-event fallback excluded attachment sends and restored them under the disposed mount's pending key. The persisted handoff now carries fileAttachments (they are plain references to already-uploaded files), the home consumer forwards them, and the recovery branch always hands off — no stranded lane remains. --- .../app/workspace/[workspaceId]/home/home.tsx | 2 +- .../home/hooks/use-chat.mount-send.test.tsx | 13 +++++++++++-- .../[workspaceId]/home/hooks/use-chat.ts | 18 +++++++----------- apps/sim/lib/core/utils/browser-storage.ts | 12 +++++++++++- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d6ed5908650..1e6ef10d6ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -370,7 +370,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const handoff = MothershipHandoffStorage.consume(workspaceId) if (!handoff) return if (handoff.message) { - sendMessage(handoff.message, undefined, handoff.contexts) + sendMessage(handoff.message, handoff.fileAttachments, handoff.contexts) return } const contexts = handoff.contexts ?? [] diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index d950b238ac1..c0aa8e76297 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -178,10 +178,17 @@ describe('useChat mount-settling send recovery', () => { }) it('re-persists an aborted chatless send as a handoff for the next mount', async () => { + const attachment = { + id: 'file-2', + key: 'uploads/file-2', + filename: 'report.pdf', + media_type: 'application/pdf', + size: 99, + } const { getResult, unmount } = renderUseChat() await act(async () => { - void getResult().sendMessage('hello from the palette') + void getResult().sendMessage('hello from the palette', [attachment]) }) await waitFor(() => state.postCalls === 1) @@ -197,7 +204,9 @@ describe('useChat mount-settling send recovery', () => { await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null) expect(allQueuedMessages()).toHaveLength(0) - expect(MothershipHandoffStorage.consume('ws-1')?.message).toBe('hello from the palette') + const handoff = MothershipHandoffStorage.consume('ws-1') + expect(handoff?.message).toBe('hello from the palette') + expect(handoff?.fileAttachments).toEqual([attachment]) }) it('does not re-queue a send the server already received', async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index f6a06efe61b..5f86ef8409f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -4506,26 +4506,22 @@ export function useChat( the cleanup that aborted this send belongs to a full remount — a queue restore would orphan the message under the dead instance's key. Deliver to the replacement surface instead: its send listener - is live by the time this microtask executes, and the event carries - attachments. When nothing claims it (a real navigation away), a - one-shot handoff covers attachment-less sends for the next mount; - attachment payloads exceed what the handoff carries and fall back to - the queue restore. Chat-bound sends always keep the queue restore - (their key is the stable chat id). */ + is live by the time this microtask executes. When nothing claims the + event (a real navigation away), a one-shot handoff covers the next + mount; both lanes carry attachments. Chat-bound sends keep the queue + restore — their key is the stable chat id. */ if (recoverableCleanupAbort && dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) { - if (sendMothershipMessage(msg.content, msg.contexts, msg.fileAttachments)) { - return - } - if (!msg.fileAttachments?.length) { + if (!sendMothershipMessage(msg.content, msg.contexts, msg.fileAttachments)) { MothershipHandoffStorage.store( { message: msg.content, ...(msg.contexts?.length ? { contexts: msg.contexts } : {}), + ...(msg.fileAttachments?.length ? { fileAttachments: msg.fileAttachments } : {}), }, workspaceId ) - return } + return } useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, msg) } diff --git a/apps/sim/lib/core/utils/browser-storage.ts b/apps/sim/lib/core/utils/browser-storage.ts index af43319d717..f19b90934b8 100644 --- a/apps/sim/lib/core/utils/browser-storage.ts +++ b/apps/sim/lib/core/utils/browser-storage.ts @@ -4,6 +4,7 @@ */ import { createLogger } from '@sim/logger' +import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' import type { ChatContext } from '@/stores/panel' const logger = createLogger('BrowserStorage') @@ -307,6 +308,8 @@ export interface MothershipHandoff { message?: string /** Structured contexts to attach — e.g. a `logs` mention tagging a run. */ contexts?: ChatContext[] + /** Already-uploaded attachment references riding along with the message. */ + fileAttachments?: FileAttachmentForApi[] } interface StoredHandoff extends MothershipHandoff { @@ -353,6 +356,7 @@ export class MothershipHandoffStorage { contexts: message ? contexts : [...MothershipHandoffStorage.pendingContexts(workspaceId), ...contexts], + ...(handoff.fileAttachments?.length ? { fileAttachments: handoff.fileAttachments } : {}), workspaceId, timestamp: Date.now(), }) @@ -409,7 +413,13 @@ export class MothershipHandoffStorage { return null } - return { ...(data.message ? { message: data.message } : {}), contexts } + return { + ...(data.message ? { message: data.message } : {}), + contexts, + ...(Array.isArray(data.fileAttachments) && data.fileAttachments.length > 0 + ? { fileAttachments: data.fileAttachments } + : {}), + } } static clear(): boolean { From b84001dd2b5cb343469add1c84a6132b58f1c0c2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 23:20:17 -0700 Subject: [PATCH 09/12] fix(chat): probe the orphaned stream before re-sending a withdrawn send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cleanup-abort recovery treated "no response headers yet" as "the server never got it" and re-sent. It is not the same thing: the mothership chat route never reads `request.signal`, so a request it had already accepted still runs to completion — resolveOrCreateChat, persistUserMessage, and the billed turn all commit even though the client socket is gone. Re-sending blind therefore left the user with two chats and two billed runs for one message. Recovery now carries the withdrawn send's `userMessageId` as a stream id through both lanes (the live `mothership-send-message` event and the stored one-shot handoff) and through a restored queue entry. Before re-sending, the dispatcher polls that stream: when it resolves to a chat, the server already has the message, so the chat is adopted instead of sent again. Only a stream the server has no record of — a 404, i.e. genuinely never accepted — re-sends. Timing out re-sends too, which is the safe direction. Also corrects the root cause recorded in the comments. A Suspense hide/reveal cannot run this cleanup: React 19 disappears layout effects only, and this is a passive effect (verified against react-dom 19.2.4). What does run it is StrictMode's dev double-mount and a real client-side navigation away, both mid-flight — and because MothershipHandoffStorage consumes atomically, the replacement mount finds nothing left to retry. --- .../app/workspace/[workspaceId]/home/home.tsx | 8 +- .../home/hooks/use-chat.mount-send.test.tsx | 187 ++++++++++++++++-- .../[workspaceId]/home/hooks/use-chat.ts | 168 +++++++++++++--- .../w/[workflowId]/components/panel/panel.tsx | 4 +- apps/sim/lib/core/utils/browser-storage.ts | 11 ++ apps/sim/lib/mothership/events.ts | 11 +- apps/sim/stores/mothership-queue/store.ts | 9 +- apps/sim/stores/mothership-queue/types.ts | 9 + 8 files changed, 362 insertions(+), 45 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 1e6ef10d6ef..093f75c6556 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -341,7 +341,9 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const detail = (e as CustomEvent).detail if (!detail?.message) return e.preventDefault() - sendMessage(detail.message, detail.fileAttachments, detail.contexts) + sendMessage(detail.message, detail.fileAttachments, detail.contexts, { + ...(detail.recoverStreamId ? { recoverStreamId: detail.recoverStreamId } : {}), + }) } window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) @@ -370,7 +372,9 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const handoff = MothershipHandoffStorage.consume(workspaceId) if (!handoff) return if (handoff.message) { - sendMessage(handoff.message, handoff.fileAttachments, handoff.contexts) + sendMessage(handoff.message, handoff.fileAttachments, handoff.contexts, { + ...(handoff.recoverStreamId ? { recoverStreamId: handoff.recoverStreamId } : {}), + }) return } const contexts = handoff.contexts ?? [] diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index c0aa8e76297..ef7fa30297b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -1,14 +1,22 @@ /** * @vitest-environment jsdom * - * Regression tests for the mount-settling send loss: a send started on a - * fresh chat surface used to be silently dropped when React ran the unmount - * cleanup mid-flight (a Suspense hide/reveal cycles every effect shortly - * after Home mounts), aborting the fetch before it dispatched. The fix routes - * idle sends through the durable message queue and restores the queued entry - * when the cleanup abort strikes before the server received the request. + * Regression tests for the remount send loss: a send started on a fresh chat + * surface was silently dropped when the hook's unmount cleanup ran mid-flight + * and aborted the POST. Two things run that cleanup while an auto-send from a + * cross-route handoff is still in flight — StrictMode's dev double-mount, and a + * real client-side navigation away — and because `MothershipHandoffStorage` + * consumes atomically, the second mount finds nothing left to retry. + * + * (A Suspense hide/reveal does NOT cause this: React 19 disappears layout + * effects only, so this passive cleanup never runs for it.) + * + * The fix routes idle sends through the durable queue so every send has a + * recoverable entry, and recovers one the cleanup withdrew — probing the + * orphaned stream first so a request the server had already accepted is + * adopted rather than sent twice. */ -import { act, type ReactNode } from 'react' +import { act, type ReactNode, StrictMode, useEffect } from 'react' import { sleep } from '@sim/utils/helpers' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' @@ -38,9 +46,21 @@ interface NetworkState { /** How the chat POST behaves for the next call. */ postBehavior: 'hang' | 'accept' postCalls: number + /** + * Chat the orphaned-stream probe resolves to, standing in for a request the + * server accepted before the client's cleanup abort tore the socket down. + * `null` means the server has no such stream (it never accepted the request). + */ + orphanedStreamChatId: string | null + streamProbes: number } -const state: NetworkState = { postBehavior: 'hang', postCalls: 0 } +const state: NetworkState = { + postBehavior: 'hang', + postCalls: 0, + orphanedStreamChatId: null, + streamProbes: 0, +} /** An SSE response whose stream ends immediately without a terminal event. */ function emptySseResponse(): Response { @@ -55,6 +75,26 @@ function emptySseResponse(): Response { async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise { const url = String(input instanceof Request ? input.url : input) + // The orphaned-stream probe: does the server hold a stream for the send the + // cleanup abort withdrew? + if (url.includes('/api/mothership/chat/stream')) { + state.streamProbes++ + // 404 is what the server returns for a stream it never registered — i.e. + // the request really was withdrawn before it was accepted. + if (!state.orphanedStreamChatId) { + return new Response(JSON.stringify({ error: 'stream gone' }), { status: 404 }) + } + return new Response( + JSON.stringify({ + success: true, + events: [], + status: 'streaming', + chatId: state.orphanedStreamChatId, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + } + if (url.includes('/api/mothership/chat') && init?.method === 'POST') { state.postCalls++ if (state.postBehavior === 'accept') return emptySseResponse() @@ -108,6 +148,43 @@ function renderUseChat(): { } } +/** + * Mounts the hook under StrictMode with a handoff already in storage, mirroring + * `home.tsx`'s consume-and-auto-send effect. This is the production-shaped + * failure: the dev double-mount runs the passive cleanup between the two + * mounts, aborting the in-flight POST, and `consume` has already cleared the + * entry so the second mount has nothing to replay. + */ +function renderStrictModeHandoffConsumer(): { unmount: () => void } { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const container = document.createElement('div') + const root = createRoot(container) + mountedRoots.push(root) + + function Probe() { + const { sendMessage } = useChat('ws-1', undefined) + useEffect(() => { + const handoff = MothershipHandoffStorage.consume('ws-1') + if (!handoff?.message) return + sendMessage(handoff.message, handoff.fileAttachments, handoff.contexts, { + ...(handoff.recoverStreamId ? { recoverStreamId: handoff.recoverStreamId } : {}), + }) + }, [sendMessage]) + return null + } + + act(() => { + root.render( + + {() as ReactNode} + + ) + }) + + return { unmount: () => act(() => root.unmount()) } +} + /** Every queued message across all chat keys, flattened. */ function allQueuedMessages() { return Object.values(useMothershipQueueStore.getState().queues).flat() @@ -123,11 +200,13 @@ async function waitFor(predicate: () => boolean, budgetMs = 2000): Promise } } -describe('useChat mount-settling send recovery', () => { +describe('useChat remount send recovery', () => { beforeEach(() => { vi.stubGlobal('fetch', fetchStub) state.postBehavior = 'hang' state.postCalls = 0 + state.orphanedStreamChatId = null + state.streamProbes = 0 mockRequestJson.mockResolvedValue({ chats: [] }) useMothershipQueueStore.setState({ queues: {}, editing: {} }) window.sessionStorage.clear() @@ -195,11 +274,11 @@ describe('useChat mount-settling send recovery', () => { // The dispatch claimed the queue head when the optimistic send applied. expect(allQueuedMessages()).toHaveLength(0) - // The cleanup abort (the same code path the mount-settling remount runs) - // fires while the POST is still awaiting the server. A chatless surface - // regenerates its queue key per mount, so recovery re-persists the send - // as a one-shot handoff for the next mount's consumer instead of - // restoring the dead instance's queue. + // The cleanup abort (the same code path a StrictMode remount or a real + // navigation away runs) fires while the POST is still awaiting the server. + // A chatless surface regenerates its queue key per mount, so recovery + // re-persists the send as a one-shot handoff for the next mount's consumer + // instead of restoring the dead instance's queue. unmount() await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null) @@ -226,4 +305,84 @@ describe('useChat mount-settling send recovery', () => { expect(allQueuedMessages()).toHaveLength(0) expect(MothershipHandoffStorage.consume('ws-1')).toBeNull() }) + + /** + * The end-to-end failure, driven by the thing that actually runs the cleanup + * mid-flight rather than by a hand-rolled unmount. On the unfixed hook the + * handoff is consumed, the POST is aborted, and nothing survives to retry. + */ + it('keeps a cross-route handoff recoverable across a StrictMode double-mount', async () => { + MothershipHandoffStorage.store({ message: 'investigate this failed run' }, 'ws-1') + + renderStrictModeHandoffConsumer() + await waitFor(() => state.postCalls >= 1) + + // Something must still be holding the message: either the live event was + // claimed and it is queued/in flight again, or it is back in storage. + await waitFor(() => { + const stored = window.localStorage.getItem('sim_mothership_handoff') + return stored !== null || allQueuedMessages().length > 0 || state.postCalls > 1 + }) + }) + + /** + * The abort tears down the client socket but the route handler never reads + * `request.signal` — a request the server had already accepted still creates + * the chat, persists the user message, and runs (and bills) the turn. So the + * recovered send has to ask whether that happened before sending again. + */ + describe('recovered send probes the orphaned stream before re-sending', () => { + it('adopts the chat the server already created instead of sending twice', async () => { + // The server accepted the withdrawn request and registered its stream. + state.orphanedStreamChatId = 'chat-server-already-made' + + const { getResult, unmount } = renderUseChat() + await act(async () => { + void getResult().sendMessage('only once please') + }) + await waitFor(() => state.postCalls === 1) + unmount() + await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null) + + // The next mount consumes the handoff, exactly as home.tsx does. + const handoff = MothershipHandoffStorage.consume('ws-1') + expect(handoff?.recoverStreamId).toBeTruthy() + + const replacement = renderUseChat() + await act(async () => { + void replacement.getResult().sendMessage(handoff?.message as string, undefined, undefined, { + recoverStreamId: handoff?.recoverStreamId as string, + }) + }) + await waitFor(() => state.streamProbes > 0) + await waitFor(() => replacement.getResult().resolvedChatId === 'chat-server-already-made') + + expect(state.postCalls).toBe(1) + expect(allQueuedMessages()).toHaveLength(0) + }) + + it('re-sends when the server has no stream for it', async () => { + // 404 from the probe: the request really was withdrawn before acceptance. + state.orphanedStreamChatId = null + + const { getResult, unmount } = renderUseChat() + await act(async () => { + void getResult().sendMessage('please actually send me') + }) + await waitFor(() => state.postCalls === 1) + unmount() + await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null) + + const handoff = MothershipHandoffStorage.consume('ws-1') + const replacement = renderUseChat() + await act(async () => { + void replacement.getResult().sendMessage(handoff?.message as string, undefined, undefined, { + recoverStreamId: handoff?.recoverStreamId as string, + }) + }) + await waitFor(() => state.postCalls === 2) + + expect(state.streamProbes).toBeGreaterThan(0) + }) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 5f86ef8409f..a1f9228dd54 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -136,6 +136,23 @@ import type { ToolCallInfo, } from '../types' +export interface SendMessageOptions { + /** + * Stream id of a send a cleanup abort withdrew, when this call is the + * recovery of it. The dispatcher probes the id before sending and adopts the + * chat the server already created instead, when there is one. + */ + recoverStreamId?: string +} + +/** + * `true` when the send owns the transcript (rendered, or handed to reconnect), + * `false` when the caller should restore the queue entry, and the object form + * when a cleanup abort withdrew the send while its request was on the wire — + * `streamId` is what recovery probes before re-sending. + */ +type StartSendMessageResult = boolean | { kind: 'recoverable_cleanup_abort'; streamId: string } + export interface UseChatReturn { messages: ChatMessage[] isSending: boolean @@ -147,7 +164,8 @@ export interface UseChatReturn { sendMessage: ( message: string, fileAttachments?: FileAttachmentForApi[], - contexts?: ChatContext[] + contexts?: ChatContext[], + options?: SendMessageOptions ) => Promise stopGeneration: () => Promise resources: MothershipResource[] @@ -176,6 +194,16 @@ const RECONNECT_MAX_DELAY_MS = 30_000 const STREAM_BATCH_FETCH_TIMEOUT_MS = 10_000 const STREAM_CHAT_ID_RESOLVE_TIMEOUT_MS = 10_000 const CHAT_HISTORY_RECOVERY_TIMEOUT_MS = 10_000 +/** + * How long a recovered send waits to find out whether the request its cleanup + * abort withdrew had in fact been accepted by the server. The server registers + * the stream early in the request (before it responds), so a short poll is + * enough; the ceiling is deliberately low because every millisecond here delays + * a send the user is watching for. Timing out re-sends, which is the safe + * direction: a lost message is worse than a rare duplicate. + */ +const RECOVERED_SEND_PROBE_TIMEOUT_MS = 2500 +const RECOVERED_SEND_PROBE_INTERVAL_MS = 250 const STOP_REQUEST_TIMEOUT_MS = 15_000 const QUEUED_SEND_HANDOFF_STORAGE_KEY = `${STREAM_STORAGE_KEY}:queued-send-handoff` const QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY = `${STREAM_STORAGE_KEY}:queued-send-handoff-claim` @@ -3347,7 +3375,8 @@ export function useChat( ( message: string, fileAttachments?: FileAttachmentForApi[], - contexts?: ChatContext[] + contexts?: ChatContext[], + recoverStreamId?: string ): QueuedMothershipMessage => { const id = generateId() const handoffChatId = selectedChatIdRef.current ?? chatIdRef.current @@ -3367,6 +3396,7 @@ export function useChat( content: message, fileAttachments, contexts, + ...(recoverStreamId ? { recoverStreamId } : {}), ...(supersededStreamId || handoffChatId ? { queuedSendHandoff: { @@ -3456,7 +3486,7 @@ export function useChat( pendingStopOverride?: Promise | null, onOptimisticSendApplied?: () => void, queuedSendHandoff?: QueuedSendHandoffSeed - ) => { + ): Promise => { if (!message.trim() || !workspaceId) return false const pendingStop = pendingStopOverride ?? pendingStopPromiseRef.current const pendingStopStreamId = pendingStop @@ -3836,12 +3866,19 @@ export function useChat( (err instanceof Error && err.name === 'AbortError') || sendAbortSignal?.aborted === true if (sendWasAborted) { if (sendAbortSignal?.reason === 'unmount:client_cleanup' && !sendReachedServer) { - /* The mount-settling remount ran the unmount cleanup while this - send was still pre-dispatch. Nothing reached the server, so the - send is fully recoverable: withdraw the optimistic pair and - report the distinct outcome so the dispatcher redelivers it. */ + /* A remount (StrictMode's dev double-mount, or a real navigation + away) ran the unmount cleanup before this send's response headers + arrived. Nothing was rendered from it, so withdraw the optimistic + pair and report the distinct outcome so the dispatcher recovers + the message. + + `sendReachedServer` only rules out a send whose RESPONSE landed — + the request itself may well have been accepted, and the route + never reads `request.signal`, so it runs to completion either + way. Recovery therefore carries `userMessageId` as the stream id + to probe before re-sending. */ rollbackOptimisticSend() - return 'recoverable_cleanup_abort' + return { kind: 'recoverable_cleanup_abort', streamId: userMessageId } } return consumedByTranscript } @@ -3895,7 +3932,12 @@ export function useChat( ] ) const sendMessage = useCallback( - async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { + async ( + message: string, + fileAttachments?: FileAttachmentForApi[], + contexts?: ChatContext[], + options?: SendMessageOptions + ) => { if (!message.trim() || !workspaceId) return const queueStore = useMothershipQueueStore.getState() @@ -3922,23 +3964,30 @@ export function useChat( queueStore.setEditing(activeChatKey, null) } + const queued = createQueuedMessage( + message, + fileAttachments, + contexts, + options?.recoverStreamId + ) + if (sendingRef.current) { - queueStore.enqueue(activeChatKey, createQueuedMessage(message, fileAttachments, contexts)) + queueStore.enqueue(activeChatKey, queued) return } if (pendingStopPromiseRef.current) { - queueStore.enqueue(activeChatKey, createQueuedMessage(message, fileAttachments, contexts)) + queueStore.enqueue(activeChatKey, queued) void enqueueQueueDispatchRef.current({ type: 'send_head' }) return } /* Even an idle-path send goes through the durable queue: a direct - startSendMessage has no backing entry, so the cleanup abort that runs - when a Suspense hide/reveal cycles effects during mount-settling would - silently drop it. The dispatch loop claims the head immediately, so - the message never renders as queued. */ - queueStore.enqueue(activeChatKey, createQueuedMessage(message, fileAttachments, contexts)) + startSendMessage has no backing entry, so a cleanup abort mid-flight + silently drops it. The dispatch loop claims the head in the same tick + for a chatless send; a chat-bound one yields once on `cancelQueries` + first, so it can briefly show as queued. */ + queueStore.enqueue(activeChatKey, queued) void enqueueQueueDispatchRef.current({ type: 'send_head' }) }, [workspaceId, createQueuedMessage] @@ -4449,6 +4498,43 @@ export function useChat( ] ) + /** + * Answers "did the server accept the request that a cleanup abort withdrew?" + * by polling for the stream it would have registered. + * + * The abort tears down the client's socket but the route handler never reads + * `request.signal`, so an accepted request still creates the chat, persists + * the user message, and runs the turn. Re-sending in that case bills a second + * run and leaves the user with two chats, so recovery adopts the existing + * chat instead whenever this resolves one. + * + * @returns The chat the orphaned stream belongs to, or `undefined` when the + * server has no such stream (never accepted, or already gone) — in which case + * the caller re-sends. + */ + const resolveRecoveredSendChatId = useCallback( + async (streamId: string, epoch: number): Promise => { + const deadline = Date.now() + RECOVERED_SEND_PROBE_TIMEOUT_MS + while (true) { + const resolve = resolveDetachedChatForStreamRef.current + if (!resolve) return undefined + // Stop as soon as this dispatch is superseded (chat switch, unmount). + // The caller adopts what this returns, which rewrites the URL, and + // doing that after the user moved on would hijack their navigation. + if (epoch !== queueDispatchEpochRef.current) return undefined + const resolution = await resolve(streamId) + if (epoch !== queueDispatchEpochRef.current) return undefined + if (resolution.chatId) return resolution.chatId + // A terminal status means the stream existed and finished without a + // durable owner; polling cannot improve on that. + if (resolution.terminal) return undefined + if (Date.now() + RECOVERED_SEND_PROBE_INTERVAL_MS >= deadline) return undefined + await sleep(RECOVERED_SEND_PROBE_INTERVAL_MS) + } + }, + [] + ) + const dispatchQueuedMessage = useCallback( async ( msg: QueuedMothershipMessage, @@ -4483,10 +4569,8 @@ export function useChat( useMothershipQueueStore.getState().remove(dispatchChatKey, msg.id) } - const restoreQueuedMessage = ( - handoff?: QueuedSendHandoffSeed, - recoverableCleanupAbort = false - ) => { + const restoreQueuedMessage = (handoff?: QueuedSendHandoffSeed, recoverStreamId?: string) => { + const recoverableCleanupAbort = recoverStreamId !== undefined if (!handoff) { clearQueuedSendHandoffState(msg.id) } @@ -4508,22 +4592,29 @@ export function useChat( key. Deliver to the replacement surface instead: its send listener is live by the time this microtask executes. When nothing claims the event (a real navigation away), a one-shot handoff covers the next - mount; both lanes carry attachments. Chat-bound sends keep the queue - restore — their key is the stable chat id. */ + mount; both lanes carry attachments and the stream id to probe. + Chat-bound sends keep the queue restore — their key is the stable + chat id — and carry the stream id on the restored entry. */ if (recoverableCleanupAbort && dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) { - if (!sendMothershipMessage(msg.content, msg.contexts, msg.fileAttachments)) { + if ( + !sendMothershipMessage(msg.content, msg.contexts, msg.fileAttachments, recoverStreamId) + ) { MothershipHandoffStorage.store( { message: msg.content, ...(msg.contexts?.length ? { contexts: msg.contexts } : {}), ...(msg.fileAttachments?.length ? { fileAttachments: msg.fileAttachments } : {}), + ...(recoverStreamId ? { recoverStreamId } : {}), }, workspaceId ) } return } - useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, msg) + useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, { + ...msg, + ...(recoverStreamId ? { recoverStreamId } : {}), + }) } let activeQueuedSendHandoff: QueuedSendHandoffSeed | undefined = @@ -4541,6 +4632,28 @@ export function useChat( // between dispatch scheduling and this send. const liveMsg = queueAtSend[currentIndex] activeQueuedSendHandoff = options.queuedSendHandoff ?? liveMsg.queuedSendHandoff + + /* This entry is the recovery of a send a cleanup abort withdrew while + its request was already on the wire. The server never sees that + abort, so if it had accepted the request it created the chat and + persisted the message regardless — re-sending would duplicate both + the chat and the billed run. Probe the orphaned stream first and + adopt its chat instead when it exists. */ + if (liveMsg.recoverStreamId) { + const adoptedChatId = await resolveRecoveredSendChatId( + liveMsg.recoverStreamId, + options.epoch + ) + if (adoptedChatId) { + removeQueuedMessage() + adoptResolvedChatId(adoptedChatId, { + replaceHomeHistory: true, + invalidateList: true, + }) + return + } + } + const sendResult = await startSendMessage( liveMsg.content, liveMsg.fileAttachments, @@ -4551,7 +4664,10 @@ export function useChat( ) if (sendResult !== true) { - restoreQueuedMessage(activeQueuedSendHandoff, sendResult === 'recoverable_cleanup_abort') + restoreQueuedMessage( + activeQueuedSendHandoff, + typeof sendResult === 'object' ? sendResult.streamId : undefined + ) } } catch { restoreQueuedMessage(activeQueuedSendHandoff) @@ -4561,7 +4677,7 @@ export function useChat( userRemovedDuringDispatchRef.current.delete(msg.id) } }, - [startSendMessage, workspaceId] + [startSendMessage, workspaceId, resolveRecoveredSendChatId, adoptResolvedChatId] ) const runQueueDispatchLoop = useCallback(async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 510d62579f7..feba0bec19e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -489,7 +489,9 @@ export const Panel = memo(function Panel() { if (!detail?.message) return e.preventDefault() setActiveTab('copilot') - copilotSendMessage(detail.message, detail.fileAttachments, detail.contexts) + copilotSendMessage(detail.message, detail.fileAttachments, detail.contexts, { + ...(detail.recoverStreamId ? { recoverStreamId: detail.recoverStreamId } : {}), + }) } window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) diff --git a/apps/sim/lib/core/utils/browser-storage.ts b/apps/sim/lib/core/utils/browser-storage.ts index f19b90934b8..61776fa1618 100644 --- a/apps/sim/lib/core/utils/browser-storage.ts +++ b/apps/sim/lib/core/utils/browser-storage.ts @@ -310,6 +310,13 @@ export interface MothershipHandoff { contexts?: ChatContext[] /** Already-uploaded attachment references riding along with the message. */ fileAttachments?: FileAttachmentForApi[] + /** + * Set only when a cleanup abort withdrew an in-flight send and this handoff + * is the recovery of it: the aborted send's stream id. The consuming chat + * probes it before sending, so a request the server had already accepted is + * adopted rather than sent a second time. + */ + recoverStreamId?: string } interface StoredHandoff extends MothershipHandoff { @@ -357,6 +364,7 @@ export class MothershipHandoffStorage { ? contexts : [...MothershipHandoffStorage.pendingContexts(workspaceId), ...contexts], ...(handoff.fileAttachments?.length ? { fileAttachments: handoff.fileAttachments } : {}), + ...(handoff.recoverStreamId ? { recoverStreamId: handoff.recoverStreamId } : {}), workspaceId, timestamp: Date.now(), }) @@ -419,6 +427,9 @@ export class MothershipHandoffStorage { ...(Array.isArray(data.fileAttachments) && data.fileAttachments.length > 0 ? { fileAttachments: data.fileAttachments } : {}), + ...(typeof data.recoverStreamId === 'string' && data.recoverStreamId + ? { recoverStreamId: data.recoverStreamId } + : {}), } } diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts index 12679bf3d71..9259051e6ed 100644 --- a/apps/sim/lib/mothership/events.ts +++ b/apps/sim/lib/mothership/events.ts @@ -27,6 +27,13 @@ export interface MothershipSendMessageDetail { contexts?: ChatContext[] /** Already-uploaded attachments riding along with the message. */ fileAttachments?: FileAttachmentForApi[] + /** + * Set only when a cleanup abort withdrew an in-flight send and this is the + * recovery of it: the aborted send's stream id. The receiving chat probes it + * before sending, so a request the server had already accepted is adopted + * rather than sent a second time. + */ + recoverStreamId?: string } /** @@ -41,7 +48,8 @@ export interface MothershipSendMessageDetail { export function sendMothershipMessage( message: string, contexts?: ChatContext[], - fileAttachments?: FileAttachmentForApi[] + fileAttachments?: FileAttachmentForApi[], + recoverStreamId?: string ): boolean { const trimmed = message.trim() if (!trimmed) { @@ -52,6 +60,7 @@ export function sendMothershipMessage( message: trimmed, contexts, fileAttachments, + ...(recoverStreamId ? { recoverStreamId } : {}), }) logger.info('Dispatched mothership message event', { messageLength: trimmed.length, consumed }) return consumed diff --git a/apps/sim/stores/mothership-queue/store.ts b/apps/sim/stores/mothership-queue/store.ts index 5697d9ac475..a70eea369f0 100644 --- a/apps/sim/stores/mothership-queue/store.ts +++ b/apps/sim/stores/mothership-queue/store.ts @@ -99,7 +99,14 @@ export const useMothershipQueueStore = create()( const next = [...current] // Strip `queuedSendHandoff` — references the stream active at // original enqueue time; the dispatcher mints a fresh one at send. - const { queuedSendHandoff: _stale, ...rest } = next[index] + // Strip `recoverStreamId` too: it dedupes against a server-side copy + // of the PRE-edit text, which the edited message is no longer a + // duplicate of, so probing it would wrongly suppress this send. + const { + queuedSendHandoff: _stale, + recoverStreamId: _staleRecover, + ...rest + } = next[index] next[index] = { ...rest, content: patch.content, diff --git a/apps/sim/stores/mothership-queue/types.ts b/apps/sim/stores/mothership-queue/types.ts index b39dff9b8e4..dde4808a422 100644 --- a/apps/sim/stores/mothership-queue/types.ts +++ b/apps/sim/stores/mothership-queue/types.ts @@ -10,6 +10,15 @@ export interface QueuedSendHandoffSeed { export type QueuedMothershipMessage = QueuedMessage & { queuedSendHandoff?: QueuedSendHandoffSeed + /** + * Stream id (the aborted send's `userMessageId`) of a send a cleanup abort + * withdrew while its request was already on the wire. The dispatcher probes + * it before re-sending: the server does not observe `request.signal`, so a + * request it had already accepted still creates the chat and persists the + * message, and re-sending blind would duplicate both. Persisted, so a + * nav-back restore probes too. + */ + recoverStreamId?: string } // Mutable fields an in-place edit overwrites; id and index are preserved by `replaceAt`. From d6a5238d620d7152f80fb62dea931449ff255f09 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 23:35:47 -0700 Subject: [PATCH 10/12] fix(chat): never re-send on an unresolved probe, and reconnect after adopting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the orphaned-stream probe, both found by Bugbot. A probe cut short by an epoch change (unmount, chat switch) returned the same `undefined` as "the server has no such stream", so the dispatcher fell through to `startSendMessage`. After unmount the teardown has already dropped the abort controller, so that send opened a POST nothing could cancel — duplicating the very message this recovery exists to protect. The probe now reports `superseded` distinctly and the dispatcher leaves the entry queued, keeping its `recoverStreamId` so a later mount probes again. Adopting the recovered chat also invalidated only the chat list. Hydration reconnects to a live turn solely on `chatHistory.activeStreamId`, and that query is cached for MOTHERSHIP_CHAT_HISTORY_STALE_TIME — on a chat-bound recover the client normally holds a copy predating this stream, so the adopted chat rendered with the running response invisible. Adoption now invalidates the chat detail too. Both regression tests were confirmed to fail without their fix: the first re-sends (2 POSTs instead of 1), the second never invalidates. The probe stub gained a `pending` mode because a `gone` probe answers on the first attempt and leaves nothing in flight to interrupt — the earlier draft of the first test passed with the guard removed and proved nothing. --- .../home/hooks/use-chat.mount-send.test.tsx | 101 ++++++++++++++++-- .../[workspaceId]/home/hooks/use-chat.ts | 64 ++++++++--- 2 files changed, 138 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index ef7fa30297b..2abba082c87 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -47,18 +47,22 @@ interface NetworkState { postBehavior: 'hang' | 'accept' postCalls: number /** - * Chat the orphaned-stream probe resolves to, standing in for a request the - * server accepted before the client's cleanup abort tore the socket down. - * `null` means the server has no such stream (it never accepted the request). + * How the orphaned-stream probe answers: + * - `found` — the server accepted the withdrawn request and owns a chat + * - `gone` — 404, it has no such stream (never accepted) + * - `pending` — registered but no owner yet, so the probe keeps polling; + * this is the only mode that leaves a probe in flight to interrupt */ - orphanedStreamChatId: string | null + probeBehavior: 'found' | 'gone' | 'pending' + orphanedStreamChatId: string streamProbes: number } const state: NetworkState = { postBehavior: 'hang', postCalls: 0, - orphanedStreamChatId: null, + probeBehavior: 'gone', + orphanedStreamChatId: 'chat-server-already-made', streamProbes: 0, } @@ -81,7 +85,7 @@ async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise< state.streamProbes++ // 404 is what the server returns for a stream it never registered — i.e. // the request really was withdrawn before it was accepted. - if (!state.orphanedStreamChatId) { + if (state.probeBehavior === 'gone') { return new Response(JSON.stringify({ error: 'stream gone' }), { status: 404 }) } return new Response( @@ -89,7 +93,8 @@ async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise< success: true, events: [], status: 'streaming', - chatId: state.orphanedStreamChatId, + // `pending` omits the owner, so the probe keeps polling. + ...(state.probeBehavior === 'found' ? { chatId: state.orphanedStreamChatId } : {}), }), { status: 200, headers: { 'Content-Type': 'application/json' } } ) @@ -205,7 +210,7 @@ describe('useChat remount send recovery', () => { vi.stubGlobal('fetch', fetchStub) state.postBehavior = 'hang' state.postCalls = 0 - state.orphanedStreamChatId = null + state.probeBehavior = 'gone' state.streamProbes = 0 mockRequestJson.mockResolvedValue({ chats: [] }) useMothershipQueueStore.setState({ queues: {}, editing: {} }) @@ -334,7 +339,7 @@ describe('useChat remount send recovery', () => { describe('recovered send probes the orphaned stream before re-sending', () => { it('adopts the chat the server already created instead of sending twice', async () => { // The server accepted the withdrawn request and registered its stream. - state.orphanedStreamChatId = 'chat-server-already-made' + state.probeBehavior = 'found' const { getResult, unmount } = renderUseChat() await act(async () => { @@ -361,9 +366,85 @@ describe('useChat remount send recovery', () => { expect(allQueuedMessages()).toHaveLength(0) }) + /** + * Adoption alone does not surface the running turn: hydration reconnects + * only when `chatHistory.activeStreamId` is set, and that query is cached + * for 30s. Without an explicit detail invalidation the adopted chat renders + * with the live response invisible. + */ + it('invalidates the adopted chat detail so hydration can reconnect', async () => { + state.probeBehavior = 'found' + + const { getResult, unmount } = renderUseChat() + await act(async () => { + void getResult().sendMessage('surface the running turn') + }) + await waitFor(() => state.postCalls === 1) + unmount() + await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null) + + const handoff = MothershipHandoffStorage.consume('ws-1') + const replacement = renderUseChat() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + await act(async () => { + void replacement.getResult().sendMessage(handoff?.message as string, undefined, undefined, { + recoverStreamId: handoff?.recoverStreamId as string, + }) + }) + await waitFor(() => + invalidateSpy.mock.calls.some(([arg]) => { + const key = (arg as { queryKey?: unknown[] } | undefined)?.queryKey + return Array.isArray(key) && key.includes('chat-server-already-made') + }) + ) + + expect(state.postCalls).toBe(1) + }) + + /** + * A probe cut short by unmount answers "unknown", not "safe to send". + * Falling through to `startSendMessage` there would open a POST whose + * abort controller the teardown already dropped — an uncancellable request + * that duplicates the send. The entry must stay queued instead. + */ + it('does not send when the probe is cut short by unmount', async () => { + state.probeBehavior = 'gone' + const { getResult, unmount } = renderUseChat() + await act(async () => { + void getResult().sendMessage('do not zombie me') + }) + await waitFor(() => state.postCalls === 1) + unmount() + await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null) + + const handoff = MothershipHandoffStorage.consume('ws-1') + + /* `pending` keeps the probe polling instead of answering on the first + attempt, which is what leaves one in flight to interrupt. A `gone` + probe answers immediately and the re-send would already have happened + before the unmount — that version of this test cannot fail. */ + state.probeBehavior = 'pending' + const replacement = renderUseChat() + await act(async () => { + void replacement.getResult().sendMessage(handoff?.message as string, undefined, undefined, { + recoverStreamId: handoff?.recoverStreamId as string, + }) + }) + await waitFor(() => state.streamProbes > 0) + const postsBeforeUnmount = state.postCalls + replacement.unmount() + + // Well past the probe's poll budget: nothing may send after teardown. + await act(async () => { + await sleep(3000) + }) + expect(state.postCalls).toBe(postsBeforeUnmount) + }) + it('re-sends when the server has no stream for it', async () => { // 404 from the probe: the request really was withdrawn before acceptance. - state.orphanedStreamChatId = null + state.probeBehavior = 'gone' const { getResult, unmount } = renderUseChat() await act(async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index a1f9228dd54..f1ff6b9da4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -153,6 +153,17 @@ export interface SendMessageOptions { */ type StartSendMessageResult = boolean | { kind: 'recoverable_cleanup_abort'; streamId: string } +/** + * Outcome of asking the server whether it accepted the request a cleanup abort + * withdrew. `superseded` is deliberately distinct from `not_found`: the former + * means the answer is unknown because this dispatch is no longer current, and + * must not be treated as licence to re-send. + */ +type RecoveredSendProbe = + | { status: 'adopted'; chatId: string } + | { status: 'not_found' } + | { status: 'superseded' } + export interface UseChatReturn { messages: ChatMessage[] isSending: boolean @@ -4513,22 +4524,28 @@ export function useChat( * the caller re-sends. */ const resolveRecoveredSendChatId = useCallback( - async (streamId: string, epoch: number): Promise => { + async (streamId: string, epoch: number): Promise => { const deadline = Date.now() + RECOVERED_SEND_PROBE_TIMEOUT_MS while (true) { const resolve = resolveDetachedChatForStreamRef.current - if (!resolve) return undefined - // Stop as soon as this dispatch is superseded (chat switch, unmount). - // The caller adopts what this returns, which rewrites the URL, and - // doing that after the user moved on would hijack their navigation. - if (epoch !== queueDispatchEpochRef.current) return undefined + if (!resolve) return { status: 'not_found' } + /* "Superseded" is NOT the same answer as "the server has no such + stream", and the caller must not conflate them: adopting rewrites + the URL, and re-sending after an unmount would open a POST whose + abort controller the teardown has already dropped — a zombie request + nothing can cancel, duplicating the very send this recovery exists + to protect. Reported distinctly so the caller leaves the entry + queued for a later mount instead. */ + if (epoch !== queueDispatchEpochRef.current) return { status: 'superseded' } const resolution = await resolve(streamId) - if (epoch !== queueDispatchEpochRef.current) return undefined - if (resolution.chatId) return resolution.chatId + if (epoch !== queueDispatchEpochRef.current) return { status: 'superseded' } + if (resolution.chatId) return { status: 'adopted', chatId: resolution.chatId } // A terminal status means the stream existed and finished without a // durable owner; polling cannot improve on that. - if (resolution.terminal) return undefined - if (Date.now() + RECOVERED_SEND_PROBE_INTERVAL_MS >= deadline) return undefined + if (resolution.terminal) return { status: 'not_found' } + if (Date.now() + RECOVERED_SEND_PROBE_INTERVAL_MS >= deadline) { + return { status: 'not_found' } + } await sleep(RECOVERED_SEND_PROBE_INTERVAL_MS) } }, @@ -4640,16 +4657,23 @@ export function useChat( the chat and the billed run. Probe the orphaned stream first and adopt its chat instead when it exists. */ if (liveMsg.recoverStreamId) { - const adoptedChatId = await resolveRecoveredSendChatId( - liveMsg.recoverStreamId, - options.epoch - ) - if (adoptedChatId) { + const probe = await resolveRecoveredSendChatId(liveMsg.recoverStreamId, options.epoch) + /* Unknown, not "safe to send" — leave the entry queued (it keeps its + `recoverStreamId`) so the next mount's drain probes again. */ + if (probe.status === 'superseded') return + if (probe.status === 'adopted') { removeQueuedMessage() - adoptResolvedChatId(adoptedChatId, { + adoptResolvedChatId(probe.chatId, { replaceHomeHistory: true, invalidateList: true, }) + /* Adoption alone does not surface the running turn. Hydration only + reconnects when `chatHistory.activeStreamId` is set, and that + query is cached for `MOTHERSHIP_CHAT_HISTORY_STALE_TIME` — on a + chat-bound recover the client usually holds a copy predating this + stream, so without an explicit detail invalidation the adopted + chat renders with the live response invisible. */ + invalidateChatQueries({ includeDetail: true, targetChatId: probe.chatId }) return } } @@ -4677,7 +4701,13 @@ export function useChat( userRemovedDuringDispatchRef.current.delete(msg.id) } }, - [startSendMessage, workspaceId, resolveRecoveredSendChatId, adoptResolvedChatId] + [ + startSendMessage, + workspaceId, + resolveRecoveredSendChatId, + adoptResolvedChatId, + invalidateChatQueries, + ] ) const runQueueDispatchLoop = useCallback(async () => { From 39e2a845450c11d42bcf1e2a63563d5c08bae9db Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 23:43:31 -0700 Subject: [PATCH 11/12] test(chat): cover the departing surface's own recovery-event claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile flagged that a surface being torn down could claim the recovery event its own cleanup emits — which would return `true`, suppress the storage fallback, and strand the message under a disposed pending key. It cannot: React removes the listener during the same synchronous unmount commit, while the recovery runs from the fetch rejection a microtask later, so by then nothing of the departing surface is listening. That ordering was previously only argued, never asserted — the suite unmounted a bare hook with no listener attached. This mounts a home.tsx-shaped surface that both drives useChat and registers the claiming listener, and asserts the departing listener claims zero times while the handoff still reaches storage. Confirmed meaningful: neutering the listener's removeEventListener cleanup so it survives teardown makes it claim, and the test fails. --- .../home/hooks/use-chat.mount-send.test.tsx | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index 2abba082c87..c2c43686056 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -190,6 +190,65 @@ function renderStrictModeHandoffConsumer(): { unmount: () => void } { return { unmount: () => act(() => root.unmount()) } } +/** + * Mounts a surface shaped like `home.tsx`: it drives `useChat` AND registers + * the `mothership-send-message` listener that claims the event with + * `preventDefault`. Unmounting this exercises the ordering question — whether + * the departing surface's own still-attached listener can claim the recovery + * event its own teardown emitted, which would suppress the storage fallback + * and strand the message. + */ +function renderHomeLikeSurface(): { + getResult: () => ReturnType + claimedByOwnListener: () => number + unmount: () => void +} { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const container = document.createElement('div') + const root = createRoot(container) + mountedRoots.push(root) + let result: ReturnType | undefined + let claims = 0 + + function HomeLike() { + const chat = useChat('ws-1', undefined) + result = chat + const { sendMessage } = chat + // Mirrors home.tsx:339 — declared AFTER useChat, so on unmount React runs + // useChat's cleanup (which aborts) before this removeEventListener. + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent<{ message?: string; recoverStreamId?: string }>).detail + if (!detail?.message) return + claims++ + e.preventDefault() + sendMessage(detail.message, undefined, undefined, { + ...(detail.recoverStreamId ? { recoverStreamId: detail.recoverStreamId } : {}), + }) + } + window.addEventListener('mothership-send-message', handler) + return () => window.removeEventListener('mothership-send-message', handler) + }, [sendMessage]) + return null + } + + act(() => { + root.render( + {() as ReactNode} + ) + }) + + return { + getResult: () => { + if (result === undefined) throw new Error('Hook result is not ready') + return result + }, + claimedByOwnListener: () => claims, + unmount: () => act(() => root.unmount()), + } +} + /** Every queued message across all chat keys, flattened. */ function allQueuedMessages() { return Object.values(useMothershipQueueStore.getState().queues).flat() @@ -311,6 +370,26 @@ describe('useChat remount send recovery', () => { expect(MothershipHandoffStorage.consume('ws-1')).toBeNull() }) + /** + * A departing surface's own listener must not claim the recovery event its + * teardown emitted: claiming returns `true`, which suppresses the storage + * fallback, and the enqueue would land under the disposed pending key — the + * message would be stranded exactly where this fix is supposed to save it. + */ + it('does not let a departing surface claim its own recovery event', async () => { + const surface = renderHomeLikeSurface() + await act(async () => { + void surface.getResult().sendMessage('must survive my own teardown') + }) + await waitFor(() => state.postCalls === 1) + + surface.unmount() + await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null) + + expect(surface.claimedByOwnListener()).toBe(0) + expect(MothershipHandoffStorage.consume('ws-1')?.message).toBe('must survive my own teardown') + }) + /** * The end-to-end failure, driven by the thing that actually runs the cleanup * mid-flight rather than by a hand-rolled unmount. On the unfixed hook the From 45013a4edb72842fa06bc3b5dd31ea0f88338b68 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 23:50:59 -0700 Subject: [PATCH 12/12] fix(chat): hand off a chatless send when the probe is superseded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made a superseded probe leave the entry queued rather than re-send it. That is the right retry for a chat-bound key, which is the stable chat id, but wrong for a chatless one: a `pending::` key is regenerated every mount, so anything left under it is unreachable and the message is stranded — the same loss this PR exists to prevent, just reached by a different route. A superseded probe on a pending key now goes through the same recovery lanes as the cleanup-abort path (live replacement surface, else a one-shot stored handoff), still carrying the stream id so the next surface probes before it sends. Skipped when the entry is no longer under that key, since adoption migrating it to a live chat already leaves it recoverable there. The lane is extracted so both call sites share one implementation. The existing superseded test only asserted that nothing sent, which this bug satisfied trivially; it now also asserts the message survives. Confirmed red without the fix. --- .../home/hooks/use-chat.mount-send.test.tsx | 7 +++ .../[workspaceId]/home/hooks/use-chat.ts | 61 ++++++++++++++----- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index c2c43686056..dbc50800722 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -519,6 +519,13 @@ describe('useChat remount send recovery', () => { await sleep(3000) }) expect(state.postCalls).toBe(postsBeforeUnmount) + + /* Not sending is only half of it — the message must still be + recoverable. A chatless surface's `pending::` key is regenerated per + mount, so leaving the entry there would strand it just as surely as + re-sending would have duplicated it. */ + expect(MothershipHandoffStorage.consume('ws-1')?.message).toBe('do not zombie me') + expect(allQueuedMessages()).toHaveLength(0) }) it('re-sends when the server has no stream for it', async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index f1ff6b9da4a..1055f20b703 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -4586,6 +4586,30 @@ export function useChat( useMothershipQueueStore.getState().remove(dispatchChatKey, msg.id) } + /** + * Hands a chatless send to whatever surface comes next, because its + * `pending::` queue key is regenerated per mount and anything left under + * this one is unreachable. Prefers the live replacement surface's + * listener and falls back to a one-shot stored handoff for a real + * navigation away. Both lanes carry attachments and the stream id, so the + * next surface probes before it sends. + */ + const handOffChatlessRecovery = (recoverStreamId?: string) => { + if ( + !sendMothershipMessage(msg.content, msg.contexts, msg.fileAttachments, recoverStreamId) + ) { + MothershipHandoffStorage.store( + { + message: msg.content, + ...(msg.contexts?.length ? { contexts: msg.contexts } : {}), + ...(msg.fileAttachments?.length ? { fileAttachments: msg.fileAttachments } : {}), + ...(recoverStreamId ? { recoverStreamId } : {}), + }, + workspaceId + ) + } + } + const restoreQueuedMessage = (handoff?: QueuedSendHandoffSeed, recoverStreamId?: string) => { const recoverableCleanupAbort = recoverStreamId !== undefined if (!handoff) { @@ -4613,19 +4637,7 @@ export function useChat( Chat-bound sends keep the queue restore — their key is the stable chat id — and carry the stream id on the restored entry. */ if (recoverableCleanupAbort && dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) { - if ( - !sendMothershipMessage(msg.content, msg.contexts, msg.fileAttachments, recoverStreamId) - ) { - MothershipHandoffStorage.store( - { - message: msg.content, - ...(msg.contexts?.length ? { contexts: msg.contexts } : {}), - ...(msg.fileAttachments?.length ? { fileAttachments: msg.fileAttachments } : {}), - ...(recoverStreamId ? { recoverStreamId } : {}), - }, - workspaceId - ) - } + handOffChatlessRecovery(recoverStreamId) return } useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, { @@ -4658,9 +4670,26 @@ export function useChat( adopt its chat instead when it exists. */ if (liveMsg.recoverStreamId) { const probe = await resolveRecoveredSendChatId(liveMsg.recoverStreamId, options.epoch) - /* Unknown, not "safe to send" — leave the entry queued (it keeps its - `recoverStreamId`) so the next mount's drain probes again. */ - if (probe.status === 'superseded') return + /* Unknown, not "safe to send". A chat-bound key is the stable chat + id, so leaving the entry queued IS the retry — the next mount's + drain probes again. A chatless `pending::` key is regenerated per + mount, so the same move would strand the message under a dead key; + hand it to the recovery lanes instead, still carrying the stream + id. Skipped when the entry is no longer under this key (adoption + migrated it to a live chat), where it is already recoverable. */ + if (probe.status === 'superseded') { + if (dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) { + const queueStore = useMothershipQueueStore.getState() + const stillUnderDeadKey = (queueStore.queues[dispatchChatKey] ?? []).some( + (queued) => queued.id === msg.id + ) + if (stillUnderDeadKey) { + queueStore.remove(dispatchChatKey, msg.id) + handOffChatlessRecovery(liveMsg.recoverStreamId) + } + } + return + } if (probe.status === 'adopted') { removeQueuedMessage() adoptResolvedChatId(probe.chatId, {