diff --git a/chat-sdk/src/attachments.ts b/chat-sdk/src/attachments.ts index d89429f512..140cc310cb 100644 --- a/chat-sdk/src/attachments.ts +++ b/chat-sdk/src/attachments.ts @@ -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 { diff --git a/chat-sdk/test/attachments.test.ts b/chat-sdk/test/attachments.test.ts index e609dc391c..84f40dd14c 100644 --- a/chat-sdk/test/attachments.test.ts +++ b/chat-sdk/test/attachments.test.ts @@ -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') + }) }) diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index 6d8b0384d4..bce7c5aa27 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -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() /** Settles when the chat has released the last turn this host started. */ #turnDone: Promise = 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() diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts index 56be29bfe3..7603481e3b 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts @@ -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((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 () => {