diff --git a/chat-sdk/src/attachments.ts b/chat-sdk/src/attachments.ts index 328c9076fa..00492b2162 100644 --- a/chat-sdk/src/attachments.ts +++ b/chat-sdk/src/attachments.ts @@ -106,10 +106,11 @@ export async function uploadAttachments( ) const uploaded = results.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : [])) const reasons = results.flatMap((r) => (r.status === 'rejected' ? [r.reason] : [])) - if (reasons.length === 0) return uploaded + // A stop that lands once every upload has answered still withdraws the batch. + if (reasons.length === 0 && !signal?.aborted) return uploaded await Promise.all(uploaded.map((u) => api.deleteFile(u.s3).catch(() => {}))) // The failure that started it, not the aborts it caused in the other uploads. - throw reasons.find((reason) => !isAbortError(reason)) ?? reasons[0] + throw reasons.find((reason) => !isAbortError(reason)) ?? reasons[0] ?? abortError() } finally { signal?.removeEventListener('abort', abortBatch) } diff --git a/chat-sdk/src/chat.ts b/chat-sdk/src/chat.ts index 12d7cdf802..c673768d1f 100644 --- a/chat-sdk/src/chat.ts +++ b/chat-sdk/src/chat.ts @@ -22,6 +22,7 @@ import { conversationTitle, errorResultMessage, extractChatAnswer, + abortError, isAbortError, isErrorResult, now, @@ -167,6 +168,8 @@ class ChatImpl implements Chat { }) } } + // Nothing may start once stop() or a conversation switch has withdrawn the turn. + if (turn.controller.signal.aborted) throw abortError() turn.started = true // Listed only once the run is asked for: a send that never runs (an upload that failed // or was stopped) then has no conversation entry to take back. diff --git a/chat-sdk/test/attachments.test.ts b/chat-sdk/test/attachments.test.ts index e93a10f4be..0960b0dd11 100644 --- a/chat-sdk/test/attachments.test.ts +++ b/chat-sdk/test/attachments.test.ts @@ -413,4 +413,31 @@ describe('sendMessage with attachments', () => { expect(call.url.searchParams.get('file_key')).toMatch(/\/0\/contract\.pdf$/) expect(call.url.searchParams.get('content_type')).toBe('application/pdf') }) + + test('stop() after the uploads land but before the run starts deletes them and runs nothing', async () => { + const { fetch, calls } = fetchMock( + upload, + (c) => + c.method === 'DELETE' && c.url.pathname === '/api/w/ws/job_helpers/delete_s3_file' + ? json('deleted') + : undefined, + run, + answer + ) + const chat = createChat(options(fetch)) + const sending = chat.sendMessage('read this', { + attachments: [{ name: 'contract.pdf', data: pdf }], + attachmentsInput: { name: 'files', multiple: true } + }) + // The upload responds at once; Stop lands before the send resumes after it. + while (uploads(calls).length === 0) await Promise.resolve() + await chat.stop() + await expect(sending).rejects.toMatchObject({ name: 'AbortError' }) + expect(runs(calls)).toHaveLength(0) + const key = uploads(calls)[0].url.searchParams.get('file_key') + expect( + calls.filter((c) => c.method === 'DELETE').map((c) => c.url.searchParams.get('file_key')) + ).toEqual([key]) + expect(chat.getState()).toMatchObject({ status: 'idle', messages: [], conversations: [] }) + }) })