Files
windmill/chat-sdk/test/support.ts
T
GuilhemandClaude Opus 5 c4c9677982 feat: attach files to a flow chat message (#11185)
* feat: attach files to a flow chat message

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: store chat uploads under windmill_uploads and withdraw refused sends cleanly

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse extra files for a single-file input and keep attachments on retry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: clean up partial upload batches, withdraw a stopped send once, free retry payloads

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: list a chat conversation only once its run starts and require files where the flow does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: carry uploaded attachments on the pending chat user message

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: route rich-composer file paste through the attachment lanes and tighten comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 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>

* fix: refuse chat attachments sent without text and shorten comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve the attachments input from real flow input reads, ignoring loop iteration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: read flow input references through one parser for the model and attachments controls

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: withdraw an attachment send stopped after its uploads answered

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: discard uploads when a send is stopped as its attachments are announced

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: keep uploadAttachments' doc comment on uploadAttachments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: never delete chat uploads from workspace storage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin that a failed upload aborts the rest of its batch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:16:10 +02:00

107 lines
3.3 KiB
TypeScript

import type { FetchLike, StorageLike } from '../src/types'
export interface RecordedCall {
method: string
url: URL
headers: Record<string, string>
body: unknown
/** A body sent as is rather than as JSON (an upload). */
raw?: Blob
signal?: AbortSignal
}
export type Route = (call: RecordedCall) => Response | Promise<Response> | undefined
/** A fetch whose responses come from the first route that answers; every call is recorded. */
export function fetchMock(...routes: Route[]): { fetch: FetchLike; calls: RecordedCall[] } {
const calls: RecordedCall[] = []
const fetch: FetchLike = async (input, init) => {
const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url)
const call: RecordedCall = {
method: init?.method ?? 'GET',
url,
headers: Object.fromEntries(
Object.entries((init?.headers as Record<string, string>) ?? {}).map(([k, v]) => [k.toLowerCase(), v])
),
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
raw: init?.body instanceof Blob ? init.body : undefined,
signal: init?.signal ?? undefined
}
calls.push(call)
for (const route of routes) {
const res = await route(call)
if (res) return res
}
return new Response(`no route for ${call.method} ${url.pathname}`, { status: 404 })
}
return { fetch, calls }
}
export function json(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), {
status,
headers: { 'content-type': 'application/json' }
})
}
export function text(value: string, status = 200): Response {
return new Response(value, { status })
}
/** A `text/event-stream` body carrying one `data:` frame per event. */
export function sse(events: object[]): Response {
return new Response(events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(''), {
status: 200,
headers: { 'content-type': 'text/event-stream' }
})
}
/** Like `sse`, but a number in the list pauses that many milliseconds before the next frame. */
export function sseTimed(events: (object | number)[]): Response {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
async start(controller) {
for (const e of events) {
if (typeof e === 'number') await new Promise((r) => setTimeout(r, e))
else controller.enqueue(encoder.encode(`data: ${JSON.stringify(e)}\n\n`))
}
controller.close()
}
})
return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } })
}
export function ndjson(...events: object[]): string {
return events.map((e) => JSON.stringify(e)).join('\n') + '\n'
}
export function memoryStorage(): StorageLike & { data: Map<string, string> } {
const data = new Map<string, string>()
return {
data,
getItem: (k) => data.get(k) ?? null,
setItem: (k, v) => void data.set(k, v),
removeItem: (k) => void data.delete(k)
}
}
export function messageRow(
seq: number,
type: 'user' | 'assistant' | 'tool',
content: string,
extra: Record<string, unknown> = {}
) {
return {
id: `row-${seq}`,
conversation_id: 'conv',
message_type: type,
content,
job_id: null,
created_at: '2026-01-01T00:00:00Z',
created_seq: seq,
step_name: null,
success: true,
...extra
}
}