fix: keep a stopped turn's files for retry and let an explicit media type win

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-17 09:14:10 +02:00
co-authored by Claude Opus 5
parent 4295649c82
commit 93aa7d5fdd
4 changed files with 66 additions and 10 deletions
+7 -7
View File
@@ -46,13 +46,13 @@ export function storedAttachmentName(filename: string, mediaType: string): strin
/** The bytes of an attachment as a Blob carrying its media type. */
export function attachmentBlob(attachment: AttachmentUpload): Blob {
const data = attachment.data
if (typeof data !== 'string') {
return attachment.mediaType && attachment.mediaType !== data.type
? new Blob([data], { type: attachment.mediaType })
: data
}
return dataUrlToBlob(data, attachment.mediaType ?? 'application/octet-stream')
const data =
typeof attachment.data === 'string'
? dataUrlToBlob(attachment.data, 'application/octet-stream')
: attachment.data
return attachment.mediaType && attachment.mediaType !== data.type
? new Blob([data], { type: attachment.mediaType })
: data
}
function dataUrlToBlob(dataUrl: string, fallbackType: string): Blob {
+18
View File
@@ -383,4 +383,22 @@ describe('sendMessage with attachments', () => {
releaseRun(text('job-1'))
await sending
})
test('an explicit mediaType wins over the type a data URL declares', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await chat.sendMessage('read this', {
attachments: [
{
name: 'contract',
data: `data:application/octet-stream;base64,${btoa('%PDF')}`,
mediaType: 'application/pdf'
}
],
attachmentsInput: { name: 'files', multiple: true }
})
const call = uploads(calls)[0]
expect(call.url.searchParams.get('file_key')).toMatch(/\/0\/contract\.pdf$/)
expect(call.url.searchParams.get('content_type')).toBe('application/pdf')
})
})
@@ -173,6 +173,7 @@ export class FlowChatViewHost implements ChatViewHost {
this.#automaticScroll = true
// A conversation reopened later comes back from the server under other message ids.
this.#sentAttachments.clear()
this.#stoppedSentIds.clear()
// The queue was typed into the conversation that just went away; a message sent
// after the switch would ride out of the wrong one, so it goes back to the composer.
this.dequeueMessage()
@@ -292,6 +293,7 @@ export class FlowChatViewHost implements ChatViewHost {
last?.role === 'user' && last.pending && last.content === text ? last.id : undefined
if (sentId && (images.length > 0 || blobs.length > 0)) {
this.#sentAttachments.set(sentId, { images, blobs })
this.#inFlightSentId = sentId
}
const turn = sending.catch((e) => {
if (sentId) this.#sentAttachments.delete(sentId)
@@ -310,19 +312,26 @@ export class FlowChatViewHost implements ChatViewHost {
})
this.#turnDone = turn
await turn
// The files are kept only for a turn that failed, the one Retry is offered on: a base64
// payload per sent file would otherwise pile up for as long as the panel lives.
// The files are kept only for a turn Retry can be offered on: one that failed, or one
// stopped, whose cancelled run is synced as a failure only after the send settles. A
// base64 payload per sent file would otherwise pile up for as long as the panel lives.
if (sentId) {
if (this.#inFlightSentId === sentId) this.#inFlightSentId = undefined
const stopped = this.#stoppedSentIds.delete(sentId)
const index = this.#state.messages.findIndex((m) => m.id === sentId)
if (index === -1 || !turnFailed(this.#state.messages, index)) {
if (!stopped && (index === -1 || !turnFailed(this.#state.messages, index))) {
this.#sentAttachments.delete(sentId)
}
}
return true
}
/** The user message of the send still awaiting its turn, when it carried files. */
#inFlightSentId: string | undefined = undefined
#stoppedSentIds = new Set<string>()
/** Settles when the chat has released the last turn this host started. */
#turnDone: Promise<unknown> = Promise.resolve()
cancel = () => {
if (this.#inFlightSentId) this.#stoppedSentIds.add(this.#inFlightSentId)
// Stop means stop: what was typed during the run goes back to the composer rather
// than waiting there to go out after some later turn settles.
this.dequeueMessage()
@@ -381,6 +381,35 @@ describe('FlowChatViewHost', () => {
host.dispose()
})
// Stop settles the send before the cancelled run's failure row is synced, so the turn only
// becomes retryable after the host has seen it settle.
it('keeps the files of a stopped turn for Retry', async () => {
const { chat, set } = fakeChat(idleState({ messages: [] }))
let settle = () => {}
chat.sendMessage.mockImplementationOnce(() => {
set({ messages: [message({ id: 'u1', role: 'user', content: 'read', pending: true })] })
return new Promise<void>((resolve) => (settle = resolve))
})
chat.sendMessage.mockImplementationOnce(async () => {})
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const sending = host.sendRequest({ instructions: 'read', blobs: [pdf] })
host.cancel()
set({ messages: [message({ id: 'u1', role: 'user', content: 'read' })] })
settle()
await sending
set({
messages: [
message({ id: 'u1', role: 'user', content: 'read' }),
message({ role: 'assistant', content: 'cancelled', success: false })
]
})
host.retryRequest(0)
await new Promise((resolve) => setTimeout(resolve, 0))
const [, options] = chat.sendMessage.mock.calls[1] as any
expect(options.attachments.map((a: any) => a.name)).toEqual(['contract.pdf'])
host.dispose()
})
it('lets go of the files of a turn that succeeded', async () => {
const { chat, set } = fakeChat(idleState({ messages: [] }))
chat.sendMessage.mockImplementationOnce(async () => {