mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-22 00:02:38 +00:00
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:
co-authored by
Claude Opus 5
parent
4295649c82
commit
93aa7d5fdd
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user