Files
windmill/chat-sdk/test/support.ts
T
Ruben FiszelandClaude Fable 5.1 e8c02c04cd feat: windmill-chat sdk for chat-mode flows in external frontends and raw apps (#11117)
* feat: windmill-chat sdk for chat-mode flows in external frontends and raw apps

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018aQiZNAU8g17kWkyTryS5J

* fix: keep streamed answers until persisted, finish turns after history fallback

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018aQiZNAU8g17kWkyTryS5J

* feat: ai sdk transport and assistant-ui runtime for windmill-chat

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: finish a turn from the flow result until its answer row lands, hash chat ids without crypto.subtle

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: judge a turn answered by a persisted assistant row, wherever it was fetched

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: attribute a turn's answer to its own jobs, keep a local turn when switching conversations

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: mirror local history on every change, attribute failure-handler answers to the turn

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: new chat per token string in the React hook, idle after destroy, no reorder on view

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: recreate the hook's chat on any credential change, namespace local history per user

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: send the latest inputs from the React hook

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 22:32:45 +02:00

102 lines
3.1 KiB
TypeScript

import type { FetchLike, StorageLike } from '../src/types'
export interface RecordedCall {
method: string
url: URL
headers: Record<string, string>
body: unknown
}
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
}
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
}
}