mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
* 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>
81 lines
3.4 KiB
TypeScript
81 lines
3.4 KiB
TypeScript
import { GlobalRegistrator } from '@happy-dom/global-registrator'
|
|
// Test files share one process: the DOM globals must not outlive this file.
|
|
GlobalRegistrator.register()
|
|
|
|
import { afterAll, describe, expect, test } from 'bun:test'
|
|
import React, { act } from 'react'
|
|
import { createRoot, type Root } from 'react-dom/client'
|
|
import { useWindmillChat, type UseWindmillChat } from '../src/react'
|
|
import type { ChatOptions } from '../src/types'
|
|
import { fetchMock, memoryStorage } from './support'
|
|
|
|
afterAll(() => GlobalRegistrator.unregister())
|
|
|
|
const base: ChatOptions = {
|
|
flowPath: 'f/chat/agent',
|
|
baseUrl: 'http://wm.test',
|
|
workspace: 'ws',
|
|
fetch: fetchMock().fetch,
|
|
storage: memoryStorage()
|
|
}
|
|
|
|
/** Renders the hook and hands back what it returned, rerendering with new options on demand. */
|
|
function mountHook() {
|
|
let latest: UseWindmillChat | undefined
|
|
const Probe = (props: ChatOptions) => {
|
|
latest = useWindmillChat(props)
|
|
return null
|
|
}
|
|
const root: Root = createRoot(document.createElement('div'))
|
|
const render = (props: ChatOptions) => {
|
|
act(() => root.render(<Probe {...props} />))
|
|
return latest!
|
|
}
|
|
return { render, unmount: () => act(() => root.unmount()) }
|
|
}
|
|
|
|
describe('useWindmillChat', () => {
|
|
test('a credential change is a new chat; a new closure for the same credential is not', () => {
|
|
const { render, unmount } = mountHook()
|
|
const a = render({ ...base, token: 'user-a' }).chat
|
|
expect(render({ ...base, token: 'user-a' }).chat).toBe(a)
|
|
const b = render({ ...base, token: 'user-b' }).chat
|
|
expect(b).not.toBe(a)
|
|
|
|
const fn1 = render({ ...base, token: () => 'fn-1' }).chat
|
|
expect(fn1).not.toBe(b)
|
|
expect(render({ ...base, token: () => 'fn-2' }).chat).toBe(fn1)
|
|
|
|
const session = render({ ...base }).chat
|
|
expect(session).not.toBe(fn1)
|
|
const fn3 = render({ ...base, token: () => 'fn-3' }).chat
|
|
expect(fn3).not.toBe(session)
|
|
expect(render({ ...base, token: () => 'fn-3', storageKey: 'someone-else' }).chat).not.toBe(fn3)
|
|
unmount()
|
|
})
|
|
|
|
test('the latest inputs go with the next message', async () => {
|
|
const { fetch, calls } = fetchMock((c) => (c.method === 'POST' ? new Response('job-1') : undefined))
|
|
const { render, unmount } = mountHook()
|
|
render({ ...base, fetch, token: 'tok', inputs: { docId: 'first' } })
|
|
const hook = render({ ...base, fetch, token: 'tok', inputs: { docId: 'second' } })
|
|
// The run's stream never answers here; only the request matters.
|
|
void hook.sendMessage('hi', { inputs: { extra: true } }).catch(() => {})
|
|
await new Promise((r) => setTimeout(r, 20))
|
|
expect(calls.find((c) => c.method === 'POST')?.body).toEqual({ docId: 'second', extra: true, user_message: 'hi' })
|
|
unmount()
|
|
})
|
|
|
|
test('a token function is read through a ref, so the latest closure serves the next request', async () => {
|
|
const { fetch, calls } = fetchMock((c) => (c.url.pathname.includes('/flow_conversations/list') ? new Response('[]') : undefined))
|
|
const { render, unmount } = mountHook()
|
|
const first = render({ ...base, fetch, history: 'server', token: () => 'first' })
|
|
await act(() => first.loadConversations())
|
|
const second = render({ ...base, fetch, history: 'server', token: () => 'second' })
|
|
expect(second.chat).toBe(first.chat)
|
|
await act(() => second.loadConversations())
|
|
expect(calls.map((c) => c.headers.authorization)).toEqual(['Bearer first', 'Bearer second'])
|
|
unmount()
|
|
})
|
|
})
|