From 1c9b89ed594456749447b890ac220a747aefb516 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 6 Oct 2026 22:39:27 -0700 Subject: [PATCH 1/5] fix(mothership): guard a Send-now restored from its stored handoff against edits A Send-now whose Stop settled has its POST out under the id its stored handoff carries. Restored after a reload it had no resumeUserMessageId, so the queue guard missed it, and an edit could run as a second turn. The guard now treats a settled handoff's id as an earlier attempt. A direct send can't reach the never-sent branch (a pending Stop queues it), so that restore now treats anything but a refusal as unknown. Docs: the 1-hour claim TTL, the claim failing closed, and the superseded admission conflict classed as busy. --- .../home/hooks/use-chat.dom.test.tsx | 64 +++++++++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 17 +++-- apps/sim/lib/mothership/chat/post.ts | 6 +- .../sim/stores/mothership-queue/store.test.ts | 33 ++++++++++ apps/sim/stores/mothership-queue/store.ts | 18 ++++-- 5 files changed, 121 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx index 8317ee6f150..bf97f17052f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx @@ -2329,6 +2329,70 @@ describe('useChat remount send recovery', () => { expect(edited).toBeUndefined() }) + /** + * A Send-now whose Stop settled has its POST out under the id its stored + * handoff carries. Reloaded before the answer, the handoff comes back into the + * queue; the server may already have admitted that id, so the restored entry + * must not be editable into a second message. + */ + it.each([ + { stopRequired: false, editable: false }, + { stopRequired: true, editable: true }, + ])( + 'guards a Send-now restored from its stored handoff (Stop still required: $stopRequired)', + async ({ stopRequired, editable }) => { + const history: MothershipChatHistory = { + id: 'chat-a', + mode: 'agent', + title: 'Restored handoff', + messages: [], + activeStreamId: null, + resources: [], + } + let loadHistory: (() => void) | undefined + mockRequestJson.mockImplementation( + () => + new Promise((resolve) => { + loadHistory = () => resolve({ chat: history }) + }) + ) + writeQueuedSendHandoffState({ + id: 'restored-send-now', + chatId: 'chat-a', + workspaceId: 'ws-1', + supersededStreamId: 'previous-response', + userMessageId: 'send-now-request', + message: 'inspect the second invoice instead', + ...(stopRequired ? { stopRequired: true } : {}), + requestedAt: Date.now(), + }) + const { getResult } = renderUseChatInChat('chat-a') + /** The first moment a user could act on the restored entry, before dispatch. */ + const editAtRestore: Array['editQueuedMessage']>> = [] + let tried = false + const unsubscribe = useMothershipQueueStore.subscribe((queueState) => { + if (tried) return + if (!queueState.queues['chat-a']?.some((message) => message.id === 'restored-send-now')) + return + /** Set first: opening the entry for editing writes the store and re-enters here. */ + tried = true + editAtRestore.push(getResult().editQueuedMessage('restored-send-now')) + }) + try { + await waitFor(() => loadHistory !== undefined) + await act(async () => { + loadHistory?.() + await sleep(50) + }) + await waitFor(() => editAtRestore.length === 1) + } finally { + unsubscribe() + } + + expect(editAtRestore[0] !== undefined).toBe(editable) + } + ) + /** * A held message the server then refuses as busy is known not to be a turn * there: the server answers a retry of an admitted id as a duplicate, never 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 12a795e52ad..473e0e94269 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -239,8 +239,9 @@ interface WithdrawnSendResult { held?: boolean /** * The server refused this id outright (busy, or a predecessor still shutting - * down). It answers a retry of an admitted id as a duplicate instead, so the - * server is known not to have it, and its queue entry can be edited. + * down). Within the 1-hour claim TTL it answers a retry of an admitted id as a + * duplicate instead, so the server is known not to have it, and its queue + * entry can be edited. */ notAdmitted?: boolean /** @@ -4066,6 +4067,10 @@ export function useChat( exact: true, refetchType: 'none', }) + /* Admission's "superseded" conflict (another attempt re-took this id's + claim) also lands here with no stream named. That needs this attempt to + hold its claim past the 60s in-progress TTL before admitting, which a + lock wait of at most 5s does not reach. */ return { userMessageId, busy: true, notAdmitted: true } } /* "Already sent" with no stream for it means the earlier attempt is still @@ -4445,11 +4450,9 @@ export function useChat( ...(result.heldUntilOnline ? { retryRequired: true, heldUntilOnline: true } : {}), ...(result.held ? { retryRequired: true } : {}), ...((result.unreachable && !result.heldUntilOnline) || result.busy ? sendRetry(1) : {}), - admissionUnknown: result.notAdmitted - ? false - : result.neverSent - ? options?.resumeUserMessageId !== undefined - : true, + /* Only a refusal of this id settles it. A direct send never waits on a Stop + (one pending queues it instead), so `neverSent` cannot occur here. */ + admissionUnknown: !result.notAdmitted, ...((result.unreachable || result.busy) && activeChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) ? { heldSurface: heldSendSurface } : {}), diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index b80730c90a5..1882da5ca94 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -939,9 +939,9 @@ const CHAT_SEND_IDEMPOTENCY_PROVIDER = 'user-message' /** * Claims this send so a retry of it can be recognised. * - * Fails open: a missed deduplication costs a duplicate chat and turn, but - * refusing the send loses the user's message. Returns `undefined` when the - * store is unreachable, which sends normally with no claim to finalize. + * Fails closed: the claim is stored in Postgres (`chatSendIdempotency` forces + * database storage), so a store failure throws and the send is answered with a + * 500 rather than run without deduplication. * * The key is scoped to the caller — `userMessageId` is client-supplied, so an * unscoped one would let a user probe another's sends for their chat id. diff --git a/apps/sim/stores/mothership-queue/store.test.ts b/apps/sim/stores/mothership-queue/store.test.ts index dee3c41d3f3..7a86e7a69b7 100644 --- a/apps/sim/stores/mothership-queue/store.test.ts +++ b/apps/sim/stores/mothership-queue/store.test.ts @@ -30,6 +30,39 @@ describe('useMothershipQueueStore', () => { }) describe('replaceAt', () => { + it('treats a Send-now whose Stop settled as possibly sent under its handoff id', () => { + useMothershipQueueStore.getState().enqueue('chat-A', { + id: 'sent', + content: 'original', + queuedSendHandoff: { + id: 'sent', + chatId: 'chat-A', + supersededStreamId: 'previous-response', + userMessageId: 'send-now-request', + }, + }) + useMothershipQueueStore.getState().enqueue('chat-A', { + id: 'waiting', + content: 'original', + queuedSendHandoff: { + id: 'waiting', + chatId: 'chat-A', + supersededStreamId: 'previous-response', + userMessageId: 'not-sent-yet', + stopRequired: true, + }, + }) + useMothershipQueueStore.getState().replaceAt('chat-A', 'sent', { content: 'edited' }) + useMothershipQueueStore.getState().replaceAt('chat-A', 'waiting', { content: 'edited' }) + + const [sent, waiting] = useMothershipQueueStore.getState().queues['chat-A'] ?? [] + expect(sent).toMatchObject({ + content: 'original', + queuedSendHandoff: { userMessageId: 'send-now-request' }, + }) + expect(waiting?.content).toBe('edited') + }) + it('treats any message resuming an earlier attempt as possibly sent, unless told otherwise', () => { useMothershipQueueStore .getState() diff --git a/apps/sim/stores/mothership-queue/store.ts b/apps/sim/stores/mothership-queue/store.ts index 3068385ddf3..abcb24321b1 100644 --- a/apps/sim/stores/mothership-queue/store.ts +++ b/apps/sim/stores/mothership-queue/store.ts @@ -52,15 +52,19 @@ const initialState = { } /** - * A message resuming an earlier attempt (`resumeUserMessageId`) may already be - * a turn on the server, unless the writer knows it is not - * (`admissionUnknown: false`). Every queue write goes through this, so no path - * can queue such a message as editable by leaving the flag out. + * A message carrying an earlier attempt's id may already be a turn on the + * server, unless the writer knows it is not (`admissionUnknown: false`). The id + * rides as `resumeUserMessageId` (a withdrawn send) or as its Stop handoff's + * `userMessageId` (a Send-now restored from its stored handoff). A handoff + * still waiting on its Stop (`stopRequired`) never sent that id: the handoff is + * rewritten without it just before the POST. Every queue write goes through + * this, so no path can queue such a message as editable by leaving the flag out. */ function withAdmissionGuard(message: QueuedMothershipMessage): QueuedMothershipMessage { - if (message.resumeUserMessageId === undefined || message.admissionUnknown !== undefined) { - return message - } + const handoff = message.queuedSendHandoff + const earlierAttempt = + message.resumeUserMessageId ?? (handoff?.stopRequired ? undefined : handoff?.userMessageId) + if (earlierAttempt === undefined || message.admissionUnknown !== undefined) return message return { ...message, admissionUnknown: true } } From 159d1152fac3f08b9bd67db60f95d26111d14f7d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 6 Oct 2026 22:59:11 -0700 Subject: [PATCH 2/5] fix(mothership): answer a send superseded at admission as a duplicate Admission's superseded conflict (another attempt with this id re-took the claim) went out as a bare 409, which the client reads as a busy refusal and marks the message editable. The claim's 60s in-progress TTL can run out before admission, since branch, attachment and context preparation precede it, so that attempt may admit the turn. The route now answers it as a duplicate naming the send's id, and the client keeps the message under it. --- .../[workspaceId]/home/hooks/use-chat.ts | 7 +++--- .../mothership/chat/application/admit-turn.ts | 4 ++-- .../chat/application/send-superseded.ts | 15 ++++++++++++ apps/sim/lib/mothership/chat/post.test.ts | 24 +++++++++++++++++++ apps/sim/lib/mothership/chat/post.ts | 10 ++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 apps/sim/lib/mothership/chat/application/send-superseded.ts 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 473e0e94269..7abc6d5f27a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -4067,10 +4067,9 @@ export function useChat( exact: true, refetchType: 'none', }) - /* Admission's "superseded" conflict (another attempt re-took this id's - claim) also lands here with no stream named. That needs this attempt to - hold its claim past the 60s in-progress TTL before admitting, which a - lock wait of at most 5s does not reach. */ + /* Only the chat lock refuses without naming this id. Admission's + "superseded" conflict, where another attempt took this id's claim and may + admit it, is answered as a duplicate naming this id instead. */ return { userMessageId, busy: true, notAdmitted: true } } /* "Already sent" with no stream for it means the earlier attempt is still diff --git a/apps/sim/lib/mothership/chat/application/admit-turn.ts b/apps/sim/lib/mothership/chat/application/admit-turn.ts index b35ea6d5a58..f5897d58068 100644 --- a/apps/sim/lib/mothership/chat/application/admit-turn.ts +++ b/apps/sim/lib/mothership/chat/application/admit-turn.ts @@ -12,6 +12,7 @@ import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/avail import { insertRunSegment, withRunAdmissionLock } from '@/lib/mothership/async-runs/repository' import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' +import { ChatSendSupersededError } from '@/lib/mothership/chat/application/send-superseded' import { withChatEffortChoice } from '@/lib/mothership/chat/intent' import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' import { authorizeOrganizationChat } from '@/lib/mothership/chat/organization-chats' @@ -167,8 +168,7 @@ export const admitChatTurn = defineAuthorizedChatUseCase({ ) ) .returning({ key: idempotencyKey.key }) - if (!claim) - throw new OrchestrationError('conflict', 'This send was superseded; retry the message') + if (!claim) throw new ChatSendSupersededError() return run }) }, diff --git a/apps/sim/lib/mothership/chat/application/send-superseded.ts b/apps/sim/lib/mothership/chat/application/send-superseded.ts new file mode 100644 index 00000000000..04490087c67 --- /dev/null +++ b/apps/sim/lib/mothership/chat/application/send-superseded.ts @@ -0,0 +1,15 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** + * Admission found this send's claim taken by another attempt with the same + * `userMessageId`: this attempt held its in-progress claim past the 60s TTL + * before admitting (branch, attachment and context preparation can run that + * long), and a retry re-claimed it. That attempt may admit the turn, so the + * client must treat this like a duplicate, not a refusal. + */ +export class ChatSendSupersededError extends OrchestrationError { + constructor() { + super('conflict', 'This send was superseded; retry the message') + this.name = 'ChatSendSupersededError' + } +} diff --git a/apps/sim/lib/mothership/chat/post.test.ts b/apps/sim/lib/mothership/chat/post.test.ts index 9e804f6fca8..708a9845661 100644 --- a/apps/sim/lib/mothership/chat/post.test.ts +++ b/apps/sim/lib/mothership/chat/post.test.ts @@ -216,6 +216,7 @@ vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScop vi.mock('@/lib/mothership/chat-status', () => mothershipChatStatusMock) import { chatOperations } from '@/lib/mothership/application/operations' +import { ChatSendSupersededError } from '@/lib/mothership/chat/application/send-superseded' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { handleUnifiedChatPost } from './post' @@ -1689,6 +1690,29 @@ describe('handleUnifiedChatPost', () => { expect(response.headers.get('x-mothership-chat-id')).toBeNull() }) + /** + * Another attempt with this id took the claim while this one was still + * preparing, and may admit the turn. The client must keep the message under + * this id, so the answer names it, as a duplicate's does. + */ + it('answers a send superseded at admission as a duplicate naming its id', async () => { + admitTurn.mockRejectedValueOnce(new ChatSendSupersededError()) + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/mothership/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Hello', + workspaceId: 'ws-1', + userMessageId: 'msg-1', + createNewChat: true, + }), + }) + ) + expect(response.status).toBe(409) + await expect(response.json()).resolves.toMatchObject({ activeStreamId: 'msg-1' }) + expect(createSSEStream).not.toHaveBeenCalled() + }) + it('keeps the claim once a turn is actually streaming', async () => { const response = await handleUnifiedChatPost( new NextRequest('http://localhost/api/mothership/chat', { diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index 1882da5ca94..d7bb135fc03 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -32,6 +32,7 @@ import { loadCopilotSearchIntegrations } from '@/lib/mothership/application/load import { chatOperations } from '@/lib/mothership/application/operations' import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target' import { admitChatTurn } from '@/lib/mothership/chat/application/admit-turn' +import { ChatSendSupersededError } from '@/lib/mothership/chat/application/send-superseded' import { type AssistantImageContent, prepareOrganizationChatAttachments, @@ -1627,6 +1628,15 @@ export async function handleUnifiedChatPost(req: NextRequest) { } const applicationError = asOrchestrationError(error) + /* Another attempt with this id holds its claim and may admit the turn. Answer + as a duplicate (naming this id), so the client keeps the message under it + rather than reading a refusal it could edit into a second turn. */ + if (applicationError instanceof ChatSendSupersededError) { + return NextResponse.json( + { error: 'This message was already sent.', activeStreamId: userMessageId }, + { status: 409 } + ) + } if (applicationError?.code === 'forbidden' || applicationError?.code === 'not_found') { return NextResponse.json({ error: 'Conversation access denied' }, { status: 403 }) } From 07b32dd4b4ac240455d404832e2b9661cad48976 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 6 Oct 2026 23:56:46 -0700 Subject: [PATCH 3/5] fix(mothership): decide once whether a send's id may already be on the server Whether a queued message may already be a turn on the server was inferred in several places (the store guard, a stopRequired exemption, the handoff restore, notAdmitted/neverSent), and the stopRequired exemption was wrong: a Send-now reuses a re-queued message's earlier id, which may have been sent. startSendMessage now decides it where it chooses the id (a reused id carries its entry's flag, a fresh one is unsent until its POST, a refusal clears it) and carries that one fact on the withdrawal result, the stored handoff and the queue entry. The store guard reads only the flag, filling it in only for writers with no say. --- .../[workspaceId]/home/hooks/send-handoff.ts | 5 + .../home/hooks/use-chat.dom.test.tsx | 130 +++++++++++++++++- .../[workspaceId]/home/hooks/use-chat.ts | 64 ++++----- .../chat/application/admit-turn.test.ts | 17 +++ .../sim/stores/mothership-queue/store.test.ts | 4 +- apps/sim/stores/mothership-queue/store.ts | 18 +-- 6 files changed, 188 insertions(+), 50 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts index 61e2befef13..f68df297bf1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts @@ -39,6 +39,8 @@ export interface QueuedSendHandoffState extends QueuedSendHandoffSeed { assistantSearchLevel?: AssistantSearchLevel requestedAt: number resolveAttempts?: number + /** Whether the server may already hold `userMessageId` (see `startSendMessage`). */ + admissionUnknown?: boolean } interface QueuedSendHandoffClaim { @@ -191,6 +193,9 @@ export function readQueuedSendHandoffState(): QueuedSendHandoffState | null { organizationId: parsed.organizationId, supersededStreamId, ...(parsed.stopRequired === true ? { stopRequired: true } : {}), + ...(typeof parsed.admissionUnknown === 'boolean' + ? { admissionUnknown: parsed.admissionUnknown } + : {}), userMessageId: parsed.userMessageId, message: parsed.message, ...(Array.isArray(parsed.fileAttachments) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx index bf97f17052f..1ffccd578cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx @@ -2336,11 +2336,11 @@ describe('useChat remount send recovery', () => { * must not be editable into a second message. */ it.each([ - { stopRequired: false, editable: false }, - { stopRequired: true, editable: true }, + { stopRequired: false, admissionUnknown: true, editable: false }, + { stopRequired: true, admissionUnknown: false, editable: true }, ])( - 'guards a Send-now restored from its stored handoff (Stop still required: $stopRequired)', - async ({ stopRequired, editable }) => { + 'guards a Send-now restored from its stored handoff (possibly sent: $admissionUnknown)', + async ({ stopRequired, admissionUnknown, editable }) => { const history: MothershipChatHistory = { id: 'chat-a', mode: 'agent', @@ -2364,6 +2364,7 @@ describe('useChat remount send recovery', () => { userMessageId: 'send-now-request', message: 'inspect the second invoice instead', ...(stopRequired ? { stopRequired: true } : {}), + admissionUnknown, requestedAt: Date.now(), }) const { getResult } = renderUseChatInChat('chat-a') @@ -2393,6 +2394,127 @@ describe('useChat remount send recovery', () => { } ) + /** + * The id a Send-now stores is not always fresh: a re-queued message reuses its + * earlier attempt's id, which may already be on the server. Reloaded while the + * Stop is still pending, the restored entry must stay uneditable. + */ + it('keeps a resumed Send-now uneditable when the page reloads before its Stop settles', async () => { + const first = renderUseChatInChat('chat-a') + await act(async () => { + void first.getResult().sendMessage('Original request') + }) + await waitFor(() => state.postBodies.length === 1 && first.getResult().isSending) + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + /** The Stop never settles before the reload. */ + if (String(input).includes('/api/copilot/chat/abort')) return new Promise(() => {}) + return fetchStub(input, init) + }) + useMothershipQueueStore.getState().enqueue('chat-a', { + id: 'resumed', + content: 'sent earlier with no answer', + resumeUserMessageId: 'unanswered-attempt', + admissionUnknown: true, + }) + await act(async () => { + void first.getResult().sendNow('resumed') + }) + await waitFor(() => readQueuedSendHandoffState()?.userMessageId === 'unanswered-attempt') + first.unmount() + + /** After the reload: the previous turn is over and the stored handoff comes back. */ + const history: MothershipChatHistory = { + id: 'chat-a', + mode: 'agent', + title: 'Reloaded', + messages: [], + activeStreamId: null, + resources: [], + } + let loadHistory: (() => void) | undefined + mockRequestJson.mockImplementation( + () => + new Promise((resolve) => { + loadHistory = () => resolve({ chat: history }) + }) + ) + const reloaded = renderUseChatInChat('chat-a') + const editAtRestore: Array['editQueuedMessage']>> = [] + let tried = false + const unsubscribe = useMothershipQueueStore.subscribe((queueState) => { + if (tried) return + const restored = queueState.queues['chat-a']?.find( + (message) => message.content === 'sent earlier with no answer' + ) + if (!restored) return + tried = true + editAtRestore.push(reloaded.getResult().editQueuedMessage(restored.id)) + }) + try { + await waitFor(() => loadHistory !== undefined) + await act(async () => { + loadHistory?.() + await sleep(50) + }) + await waitFor(() => tried) + } finally { + unsubscribe() + } + + expect(editAtRestore[0]).toBeUndefined() + }) + + /** + * A send superseded at admission is answered like a duplicate, naming its own + * id with no stream yet: another attempt holding that id may still admit it. + * The message waits uneditable, then goes out again under the same id. + */ + it('keeps a send answered as a duplicate with no stream uneditable, then retries it', async () => { + const history: MothershipChatHistory = { + id: 'chat-superseded-send', + mode: 'agent', + title: 'Superseded', + messages: [], + activeStreamId: null, + resources: [], + } + mockRequestJson.mockImplementation(() => Promise.resolve({ chat: history })) + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url === '/api/mothership/chat' && init?.method === 'POST') { + state.postBodies.push(JSON.parse(String(init.body))) + if (state.postBodies.length === 1) { + return Response.json( + { + error: 'This message was already sent.', + activeStreamId: state.postBodies[0].userMessageId, + }, + { status: 409 } + ) + } + return emptySseResponse() + } + if (url.includes('/api/mothership/chat/stream') && state.postBodies.length === 1) { + return Response.json({ error: 'Stream not found' }, { status: 404 }) + } + return fetchStub(input, init) + }) + const { getResult } = renderUseChatInChat(history.id, history) + await act(async () => { + await getResult().sendMessage('Superseded at admission') + }) + const waiting = useMothershipQueueStore.getState().queues[history.id]?.[0] + let edited: ReturnType['editQueuedMessage']> + await act(async () => { + edited = getResult().editQueuedMessage(waiting?.id ?? '') + }) + + expect(waiting?.admissionUnknown).toBe(true) + expect(edited).toBeUndefined() + await waitFor(() => state.postBodies.length === 2, 5_000) + expect(state.postBodies[1].userMessageId).toBe(state.postBodies[0].userMessageId) + }) + /** * A held message the server then refuses as busy is known not to be a turn * there: the server answers a retry of an admitted id as a duplicate, never 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 7abc6d5f27a..4660b93d219 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -237,19 +237,8 @@ interface WithdrawnSendResult { busy?: boolean /** Not sent at all (its Stop handoff failed); kept queued for the user to send. */ held?: boolean - /** - * The server refused this id outright (busy, or a predecessor still shutting - * down). Within the 1-hour claim TTL it answers a retry of an admitted id as a - * duplicate instead, so the server is known not to have it, and its queue - * entry can be edited. - */ - notAdmitted?: boolean - /** - * This attempt never reached the server (its Stop did not settle). That says - * nothing about an earlier attempt the message resumes, whose uncertainty it - * keeps. - */ - neverSent?: boolean + /** Whether the server may hold `userMessageId`; see `admissionUnknown` in `startSendMessage`. */ + admissionUnknown: boolean } /** @@ -273,6 +262,8 @@ interface StartSendMessageOptions { * opening a second chat and billing a second turn. */ resumeUserMessageId?: string + /** The queued entry's `admissionUnknown`, for the id it reuses. */ + admissionUnknown?: boolean requestMode?: ChatRequestMode assistantSearch?: WorkspaceSearchFilters assistantSearchLevel?: AssistantSearchLevel @@ -3586,8 +3577,15 @@ export function useChat( /* A retry of a withdrawn send reuses its id so the server deduplicates the two attempts; anything else mints a fresh one. */ - const userMessageId = - queuedSendHandoff?.userMessageId ?? options?.resumeUserMessageId ?? generateId() + const reusedId = queuedSendHandoff?.userMessageId ?? options?.resumeUserMessageId + const userMessageId = reusedId ?? generateId() + /* Whether the server may already hold `userMessageId`: the one fact that keeps a + queued message from being edited into a second turn. A reused id may have been + sent before, unless its entry knows the server refused it; a fresh id is unsent + until its POST goes out. Only the server refusing the id clears it again + (within the 1-hour claim TTL a retry of an admitted id is answered as a + duplicate, never refused). It rides the stored handoff and every withdrawal. */ + let admissionUnknown = reusedId !== undefined && options?.admissionUnknown !== false const assistantId = getLiveAssistantMessageId(userMessageId) const storedAttachments: PersistedFileAttachment[] | undefined = @@ -3637,6 +3635,8 @@ export function useChat( organizationId, supersededStreamId: queuedSendHandoff.supersededStreamId, ...(queuedSendHandoff.stopRequired ? { stopRequired: true } : {}), + /** Without a pending Stop, its POST goes out next. */ + admissionUnknown: admissionUnknown || !queuedSendHandoff.stopRequired, userMessageId, message, ...(fileAttachments ? { fileAttachments } : {}), @@ -3911,7 +3911,7 @@ export function useChat( setError(getErrorMessage(err, 'Failed to stop the previous response')) /* Nothing was sent. Hand the message back so it stays in its chat's queue even if the user has switched chats since the Stop began. */ - return { userMessageId, held: true, neverSent: true } + return { userMessageId, held: true, admissionUnknown } } } @@ -3935,6 +3935,7 @@ export function useChat( ? {} : await getDesktopChatCapabilities(desktopScopeIdRef.current) + admissionUnknown = true const response = await fetch(apiPathRef.current, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -4031,7 +4032,7 @@ export function useChat( } if (viewOnSend) setError('Previous response is still shutting down; queued message was restored.') - return { userMessageId, held: true, notAdmitted: true } + return { userMessageId, held: true, admissionUnknown: false } } /** Withdraws this refused send so the queue retries it, under the same id, later. */ const releaseRefusedSend = () => { @@ -4070,7 +4071,7 @@ export function useChat( /* Only the chat lock refuses without naming this id. Admission's "superseded" conflict, where another attempt took this id's claim and may admit it, is answered as a duplicate naming this id instead. */ - return { userMessageId, busy: true, notAdmitted: true } + return { userMessageId, busy: true, admissionUnknown: false } } /* "Already sent" with no stream for it means the earlier attempt is still in flight on the server (or died before starting a turn), not that a turn @@ -4092,7 +4093,7 @@ export function useChat( ) if (!dedupedStreamExists) { releaseRefusedSend() - return { userMessageId, busy: true } + return { userMessageId, busy: true, admissionUnknown } } /** The user may have moved on (another chat, another send) during the check. */ if (streamGenRef.current !== gen) return consumedByTranscript @@ -4203,7 +4204,7 @@ export function useChat( server deduplicates it against that turn instead of billing another one. */ rollbackOptimisticSend() - return { userMessageId } + return { userMessageId, admissionUnknown } } return consumedByTranscript } @@ -4245,6 +4246,7 @@ export function useChat( ) return { userMessageId, + admissionUnknown, unreachable: true, ...(retryLater ? {} : { heldUntilOnline: true }), } @@ -4449,9 +4451,7 @@ export function useChat( ...(result.heldUntilOnline ? { retryRequired: true, heldUntilOnline: true } : {}), ...(result.held ? { retryRequired: true } : {}), ...((result.unreachable && !result.heldUntilOnline) || result.busy ? sendRetry(1) : {}), - /* Only a refusal of this id settles it. A direct send never waits on a Stop - (one pending queues it instead), so `neverSent` cannot occur here. */ - admissionUnknown: !result.notAdmitted, + admissionUnknown: result.admissionUnknown, ...((result.unreachable || result.busy) && activeChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) ? { heldSurface: heldSendSurface } : {}), @@ -4667,6 +4667,9 @@ export function useChat( userMessageId: handoff.userMessageId, ...(handoff.stopRequired ? { stopRequired: true } : {}), }, + ...(handoff.admissionUnknown !== undefined + ? { admissionUnknown: handoff.admissionUnknown } + : {}), }) clearQueuedSendHandoffState(handoff.id) clearQueuedSendHandoffClaim(handoff.id) @@ -5083,17 +5086,7 @@ export function useChat( ? { heldSurface: heldSendSurface } : {}), ...(withdrawnUserMessageId ? { resumeUserMessageId: withdrawnUserMessageId } : {}), - /* A refusal of this id settles it; an attempt that never left keeps the - earlier uncertainty; any other withdrawal may have reached the server. */ - ...(withdrawn - ? { - admissionUnknown: withdrawn.notAdmitted - ? false - : withdrawn.neverSent - ? dispatched.admissionUnknown === true - : true, - } - : {}), + ...(withdrawn ? { admissionUnknown: withdrawn.admissionUnknown } : {}), }) } @@ -5125,6 +5118,9 @@ export function useChat( ...(liveMsg.resumeUserMessageId ? { resumeUserMessageId: liveMsg.resumeUserMessageId } : {}), + ...(liveMsg.admissionUnknown !== undefined + ? { admissionUnknown: liveMsg.admissionUnknown } + : {}), ...(liveMsg.requestMode ? { requestMode: liveMsg.requestMode } : {}), ...(liveMsg.assistantSearch ? { assistantSearch: liveMsg.assistantSearch } : {}), ...(liveMsg.assistantSearchLevel !== undefined diff --git a/apps/sim/lib/mothership/chat/application/admit-turn.test.ts b/apps/sim/lib/mothership/chat/application/admit-turn.test.ts index dd43615f58f..17475342241 100644 --- a/apps/sim/lib/mothership/chat/application/admit-turn.test.ts +++ b/apps/sim/lib/mothership/chat/application/admit-turn.test.ts @@ -23,6 +23,7 @@ import { } from '@sim/testing/mocks/permission-groups-resolve.mock' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { admitChatTurn, turnDesktopDevice } from '@/lib/mothership/chat/application/admit-turn' +import { ChatSendSupersededError } from '@/lib/mothership/chat/application/send-superseded' const hoisted = vi.hoisted(() => ({ lease: vi.fn(), @@ -123,6 +124,22 @@ describe('organization turn admission through current private-chat authorization expect.anything() ) }) + /** + * Another attempt with this id re-took the claim after this one's in-progress + * TTL ran out. That attempt may admit the turn, so this one must not, and the + * route answers it as a duplicate. + */ + it('refuses to admit a send whose claim another attempt took', async () => { + queueTableRows(copilotChats, [chat]) + queueTableRows(member, [{ role: 'member' }]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ model: null }]) + .mockResolvedValueOnce([{ id: 'run-1', organizationId: 'org-1', workspaceId: null }]) + .mockResolvedValueOnce([]) + await expect(admitChatTurn.execute({ principal, input: input() })).rejects.toBeInstanceOf( + ChatSendSupersededError + ) + }) it("keeps an organization chat's turn with its chat view, never on a desktop", async () => { hoisted.resolveDesktop.mockResolvedValue('device-1') const offered = '33333333-3333-4333-8333-333333333333' diff --git a/apps/sim/stores/mothership-queue/store.test.ts b/apps/sim/stores/mothership-queue/store.test.ts index 7a86e7a69b7..49cd2f3a0ad 100644 --- a/apps/sim/stores/mothership-queue/store.test.ts +++ b/apps/sim/stores/mothership-queue/store.test.ts @@ -30,7 +30,7 @@ describe('useMothershipQueueStore', () => { }) describe('replaceAt', () => { - it('treats a Send-now whose Stop settled as possibly sent under its handoff id', () => { + it('reads a reused handoff id as possibly sent unless the entry says otherwise', () => { useMothershipQueueStore.getState().enqueue('chat-A', { id: 'sent', content: 'original', @@ -51,6 +51,8 @@ describe('useMothershipQueueStore', () => { userMessageId: 'not-sent-yet', stopRequired: true, }, + /** A fresh id still waiting on its Stop, as the hook records it. */ + admissionUnknown: false, }) useMothershipQueueStore.getState().replaceAt('chat-A', 'sent', { content: 'edited' }) useMothershipQueueStore.getState().replaceAt('chat-A', 'waiting', { content: 'edited' }) diff --git a/apps/sim/stores/mothership-queue/store.ts b/apps/sim/stores/mothership-queue/store.ts index abcb24321b1..96a30d4c096 100644 --- a/apps/sim/stores/mothership-queue/store.ts +++ b/apps/sim/stores/mothership-queue/store.ts @@ -52,19 +52,15 @@ const initialState = { } /** - * A message carrying an earlier attempt's id may already be a turn on the - * server, unless the writer knows it is not (`admissionUnknown: false`). The id - * rides as `resumeUserMessageId` (a withdrawn send) or as its Stop handoff's - * `userMessageId` (a Send-now restored from its stored handoff). A handoff - * still waiting on its Stop (`stopRequired`) never sent that id: the handoff is - * rewritten without it just before the POST. Every queue write goes through - * this, so no path can queue such a message as editable by leaving the flag out. + * `admissionUnknown` is decided where a send chooses its id (`startSendMessage`) + * and carried on the entry. A writer that has no say (a session saved before the + * flag existed, a send handed over from another surface) leaves it out; an entry + * that then reuses an earlier attempt's id is taken as possibly sent. Every queue + * write goes through this, so no path can queue such a message as editable. */ function withAdmissionGuard(message: QueuedMothershipMessage): QueuedMothershipMessage { - const handoff = message.queuedSendHandoff - const earlierAttempt = - message.resumeUserMessageId ?? (handoff?.stopRequired ? undefined : handoff?.userMessageId) - if (earlierAttempt === undefined || message.admissionUnknown !== undefined) return message + const reusedId = message.resumeUserMessageId ?? message.queuedSendHandoff?.userMessageId + if (reusedId === undefined || message.admissionUnknown !== undefined) return message return { ...message, admissionUnknown: true } } From 97f5c94aac7a667847b3ff84cd9a6d9aa13a4017 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 00:43:15 -0700 Subject: [PATCH 4/5] fix(mothership): align the one admission fact with the history check after rebase - one helper names the id a queued entry reuses (its Stop handoff's, else the withdrawn send's), in the order startSendMessage sends it - a conflict naming the send's own id is never read as a refusal - the stored handoff records the flag as it stands, rewritten as the POST goes out, instead of inferring it from stopRequired - the Stop-settlement test's handoff carries the flag the hook writes; a flagless legacy handoff is checked against history before it is resent --- .../home/hooks/use-chat.dom.test.tsx | 47 +++++++++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 17 ++++--- .../sim/stores/mothership-queue/store.test.ts | 21 +++++++++ apps/sim/stores/mothership-queue/store.ts | 14 +++++- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx index 1ffccd578cf..e1fcc8d6a0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx @@ -1871,6 +1871,51 @@ describe('useChat remount send recovery', () => { }) }) + /** + * A handoff stored before the flag existed cannot say whether its id was ever + * sent, so it is checked against the chat's history before it goes out again. + */ + it('checks a flagless stored handoff against history before resending it', async () => { + const order: string[] = [] + const history: MothershipChatHistory = { + id: 'chat-a', + mode: 'agent', + title: 'Invoice inspection', + messages: [], + activeStreamId: null, + resources: [], + } + mockRequestJson.mockImplementation(() => { + order.push('history') + return Promise.resolve({ chat: history }) + }) + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input) + if (url.includes('/api/copilot/chat/abort')) { + state.abortBodies.push(JSON.parse(String(init?.body))) + return Response.json({ aborted: true, settled: true }) + } + if (url === '/api/mothership/chat' && init?.method === 'POST') order.push('post') + return fetchStub(input, init) + }) + writeQueuedSendHandoffState({ + id: 'queued-correction', + chatId: 'chat-a', + workspaceId: 'ws-1', + supersededStreamId: 'previous-response', + userMessageId: 'prepared-correction-request', + message: 'inspect the second invoice instead', + stopRequired: true, + requestedAt: Date.now(), + }) + renderUseChatInChat('chat-a', history) + await waitFor(() => state.postBodies.length === 1, 4_000) + + expect(state.postBodies[0].userMessageId).toBe('prepared-correction-request') + expect(order.indexOf('history')).toBeGreaterThanOrEqual(0) + expect(order.indexOf('history')).toBeLessThan(order.indexOf('post')) + }) + it.each([false, true])( 'the remounted handoff reader requires Stop settlement (settled: %s)', async (settled) => { @@ -1892,6 +1937,8 @@ describe('useChat remount send recovery', () => { message: 'inspect the second invoice instead', requestMode: 'assistant', stopRequired: true, + /** A fresh id still waiting on its Stop, as the hook records it. */ + admissionUnknown: false, requestedAt: Date.now(), }) const { getResult } = renderUseChatInChat('chat-a', { 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 4660b93d219..05effd4f536 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -134,7 +134,7 @@ import { workflowKeys } from '@/hooks/queries/workflows' import { snapAllSmoothText } from '@/hooks/use-smooth-text' import { useChatPanelStore } from '@/stores/chat-panel/store' import { useMothershipEffortStore } from '@/stores/mothership-effort/store' -import { useMothershipQueueStore } from '@/stores/mothership-queue/store' +import { reusedRequestId, useMothershipQueueStore } from '@/stores/mothership-queue/store' import type { QueuedMothershipMessage, QueuedSendHandoffSeed, @@ -3635,8 +3635,7 @@ export function useChat( organizationId, supersededStreamId: queuedSendHandoff.supersededStreamId, ...(queuedSendHandoff.stopRequired ? { stopRequired: true } : {}), - /** Without a pending Stop, its POST goes out next. */ - admissionUnknown: admissionUnknown || !queuedSendHandoff.stopRequired, + admissionUnknown, userMessageId, message, ...(fileAttachments ? { fileAttachments } : {}), @@ -3936,6 +3935,8 @@ export function useChat( : await getDesktopChatCapabilities(desktopScopeIdRef.current) admissionUnknown = true + /** A reload from here on may find the server holding this id. */ + writeQueuedSendHandoff(requestChatId) const response = await fetch(apiPathRef.current, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -4021,7 +4022,11 @@ export function useChat( /** Whether this view still shows the send; otherwise only its own chat changes. */ const viewOnSend = streamGenRef.current === gen const supersededStreamId = queuedSendHandoff?.supersededStreamId ?? pendingStopStreamId - if (supersededStreamId && conflictStreamId === supersededStreamId) { + if ( + supersededStreamId && + conflictStreamId === supersededStreamId && + conflictStreamId !== userMessageId + ) { rollbackOptimisticSend() if (streamGenRef.current === gen) { streamGenRef.current++ @@ -5156,7 +5161,7 @@ export function useChat( */ const mustNotResend = useCallback( async (chatKey: string, msg: QueuedMothershipMessage): Promise => { - const requestId = msg.queuedSendHandoff?.userMessageId ?? msg.resumeUserMessageId + const requestId = reusedRequestId(msg) if (!msg.admissionUnknown || !requestId || chatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) return false const history = await queryClient @@ -5358,7 +5363,7 @@ export function useChat( const accepted = acceptedMessageIds(chatHistory) for (const queued of messageQueue) { if (queuedMessageDispatchIds.has(queued.id)) continue - const requestId = queued.queuedSendHandoff?.userMessageId ?? queued.resumeUserMessageId + const requestId = reusedRequestId(queued) if (!requestId || !accepted.has(requestId)) continue clearQueuedSendHandoffState(queued.id) clearQueuedSendHandoffClaim(queued.id) diff --git a/apps/sim/stores/mothership-queue/store.test.ts b/apps/sim/stores/mothership-queue/store.test.ts index 49cd2f3a0ad..94e16fc6d50 100644 --- a/apps/sim/stores/mothership-queue/store.test.ts +++ b/apps/sim/stores/mothership-queue/store.test.ts @@ -30,6 +30,27 @@ describe('useMothershipQueueStore', () => { }) describe('replaceAt', () => { + it('treats a flagless handoff still waiting on its Stop as possibly sent', () => { + /** Its id may be a re-queued message's earlier attempt, which a pending Stop says nothing about. */ + useMothershipQueueStore.getState().enqueue('chat-A', { + id: 'legacy', + content: 'original', + queuedSendHandoff: { + id: 'legacy', + chatId: 'chat-A', + supersededStreamId: 'previous-response', + userMessageId: 'earlier-attempt', + stopRequired: true, + }, + }) + useMothershipQueueStore.getState().replaceAt('chat-A', 'legacy', { content: 'edited' }) + + expect(useMothershipQueueStore.getState().queues['chat-A']?.[0]).toMatchObject({ + content: 'original', + admissionUnknown: true, + }) + }) + it('reads a reused handoff id as possibly sent unless the entry says otherwise', () => { useMothershipQueueStore.getState().enqueue('chat-A', { id: 'sent', diff --git a/apps/sim/stores/mothership-queue/store.ts b/apps/sim/stores/mothership-queue/store.ts index 96a30d4c096..88c036ba526 100644 --- a/apps/sim/stores/mothership-queue/store.ts +++ b/apps/sim/stores/mothership-queue/store.ts @@ -51,6 +51,15 @@ const initialState = { cleared: {} as Record, } +/** + * The earlier attempt's id a queued message goes out under, if it reuses one: + * its Stop handoff's, else the withdrawn send's. `startSendMessage` picks the id + * in the same order. + */ +export function reusedRequestId(message: QueuedMothershipMessage): string | undefined { + return message.queuedSendHandoff?.userMessageId ?? message.resumeUserMessageId +} + /** * `admissionUnknown` is decided where a send chooses its id (`startSendMessage`) * and carried on the entry. A writer that has no say (a session saved before the @@ -59,8 +68,9 @@ const initialState = { * write goes through this, so no path can queue such a message as editable. */ function withAdmissionGuard(message: QueuedMothershipMessage): QueuedMothershipMessage { - const reusedId = message.resumeUserMessageId ?? message.queuedSendHandoff?.userMessageId - if (reusedId === undefined || message.admissionUnknown !== undefined) return message + if (reusedRequestId(message) === undefined || message.admissionUnknown !== undefined) { + return message + } return { ...message, admissionUnknown: true } } From ef6548022ece695d40386e6fca6e34e456b832c6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 01:18:58 -0700 Subject: [PATCH 5/5] test(mothership): cover the pre-POST handoff write and the own-id conflict - a fresh Send-now reloaded with its POST unanswered restores uneditable (red without the handoff rewrite as the POST goes out) - a conflict naming the resent id itself is a duplicate, never a refusal (red without the conflictStreamId !== userMessageId check) --- .../home/hooks/use-chat.dom.test.tsx | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx index e1fcc8d6a0c..2161d2b92cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx @@ -2562,6 +2562,143 @@ describe('useChat remount send recovery', () => { expect(state.postBodies[1].userMessageId).toBe(state.postBodies[0].userMessageId) }) + /** + * A fresh-id Send-now is unsent while it waits on its Stop. Once its POST goes + * out the server may hold it, even before the chat's history shows it, so a + * reload with that POST unanswered must restore it uneditable. + */ + it('keeps a fresh Send-now uneditable when the page reloads with its POST unanswered', async () => { + const first = renderUseChatInChat('chat-a') + await act(async () => { + void first.getResult().sendMessage('Original request') + }) + await waitFor(() => state.postBodies.length === 1 && first.getResult().isSending) + await act(async () => { + void first.getResult().sendMessage('Use the latest report') + }) + await waitFor(() => useMothershipQueueStore.getState().queues['chat-a']?.length === 1) + await act(async () => { + void first.getResult().sendNow() + }) + /** The Send-now POST is out, and held open. */ + await waitFor(() => state.postBodies.length === 2) + const storedAtReload = readQueuedSendHandoffState() + expect(storedAtReload?.userMessageId).toBe(state.postBodies[1].userMessageId) + first.unmount() + await act(async () => { + await sleep(50) + }) + /* A reload runs no cleanup: the queue comes back as the session saved it (the + dispatched entry had left it) and the handoff as it was stored. */ + useMothershipQueueStore.getState().reset() + if (storedAtReload) writeQueuedSendHandoffState(storedAtReload) + + const history: MothershipChatHistory = { + id: 'chat-a', + mode: 'agent', + title: 'Reloaded', + messages: [], + activeStreamId: null, + resources: [], + } + let loadHistory: (() => void) | undefined + mockRequestJson.mockImplementation( + () => + new Promise((resolve) => { + loadHistory = () => resolve({ chat: history }) + }) + ) + const reloaded = renderUseChatInChat('chat-a') + const editAtRestore: Array['editQueuedMessage']>> = [] + let tried = false + const unsubscribe = useMothershipQueueStore.subscribe((queueState) => { + if (tried) return + const restored = queueState.queues['chat-a']?.find( + (message) => message.content === 'Use the latest report' + ) + if (!restored) return + tried = true + editAtRestore.push(reloaded.getResult().editQueuedMessage(restored.id)) + }) + try { + await waitFor(() => loadHistory !== undefined) + await act(async () => { + loadHistory?.() + await sleep(50) + }) + await waitFor(() => tried) + } finally { + unsubscribe() + } + + expect(editAtRestore[0]).toBeUndefined() + }) + + /** + * A resumed message whose earlier attempt is the very turn now running, sent + * now: its Stop and its resend share one id, and the server answers the resend + * as a duplicate of that turn. That is not a refusal, so the message must + * never come back editable. + */ + it('never treats a conflict naming the resent id itself as a refusal', async () => { + const history: MothershipChatHistory = { + id: 'chat-own-id', + mode: 'agent', + title: 'Own id', + messages: [], + activeStreamId: 'earlier-attempt', + resources: [], + } + mockRequestJson.mockImplementation(() => Promise.resolve({ chat: history })) + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.includes('/api/copilot/chat/abort')) { + state.abortBodies.push(JSON.parse(String(init?.body))) + return Response.json({ aborted: true, settled: true }) + } + if (url === '/api/mothership/chat' && init?.method === 'POST') { + state.postBodies.push(JSON.parse(String(init.body))) + return Response.json( + { error: 'This message was already sent.', activeStreamId: 'earlier-attempt' }, + { status: 409 } + ) + } + if (url.includes('/api/mothership/chat/stream')) { + if (url.includes('batch=true')) { + return Response.json({ success: true, events: [], status: 'streaming' }) + } + return new Response(new ReadableStream(), { + headers: { 'Content-Type': 'text/event-stream' }, + }) + } + return fetchStub(input, init) + }) + const { getResult } = renderUseChatInChat(history.id, history) + await waitFor(() => getResult().isSending) + useMothershipQueueStore.getState().enqueue(history.id, { + id: 'resumed', + content: 'sent earlier with no answer', + resumeUserMessageId: 'earlier-attempt', + admissionUnknown: true, + }) + await act(async () => { + /** Reattaches to the running turn, which stays open. */ + void getResult() + .sendNow('resumed') + .catch(() => {}) + }) + await waitFor(() => state.postBodies.length === 1) + await act(async () => { + await sleep(300) + }) + + expect(state.postBodies.map((body) => body.userMessageId)).toEqual(['earlier-attempt']) + const requeued = useMothershipQueueStore + .getState() + .queues[history.id]?.find((message) => message.content === 'sent earlier with no answer') + expect(requeued === undefined || requeued.admissionUnknown === true).toBe(true) + }) + /** * A held message the server then refuses as busy is known not to be a turn * there: the server answers a retry of an admitted id as a duplicate, never