fix: withdraw an attachment send stopped after its uploads answered

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-17 10:49:32 +02:00
co-authored by Claude Opus 5
parent e279f918fd
commit 43ca8dc40f
3 changed files with 33 additions and 2 deletions
+3 -2
View File
@@ -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)
}
+3
View File
@@ -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.
+27
View File
@@ -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: [] })
})
})