mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
* feat: run turns in several flow chat conversations at once Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep finished turns finished and cached chats current in the flow chat pool Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: attribute a turn's rows by job id as well as sequence Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: count only real stream updates and retry the job-id read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a chat that holds an unsent draft, and take one back when its first turn is withdrawn Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: ignore a stale running-turn snapshot, keep a withdrawn chat's draft, poll after clean stream ends Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: follow the turn running now when the listing named one already over Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep replacement turns and SSE fallback moving * fix: keep replacement turn handoffs active * fix: preserve unread badge line height * fix: settle local fallback handoffs * fix: settle refused turn handoffs * fix: scope turn handoffs to conversation * fix: drop stale turn handoffs * refactor: move the queued message and 409 handling into per-conversation turns Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address cubic's review of the parallel flow chat turns Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: clear a stale failure on refresh, and tighten the docs and test waits Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: recover running rows past the first page, and drop the failure a re-read disproves Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: check the running-turn query at compile time, and narrow what a refresh clears Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: settle a failed turn only from an answer that turn wrote Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: settle a failed turn from its own answer, and only while it is still the failure shown Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a failure whose answer arrived even when a newer turn owns the error Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: free an answered failure whatever the turn that started meanwhile is doing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a rows read that a turn outran, rather than merging it under newer messages Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a rows read whose conversation was left and opened again Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hand over a file still being read when its composer goes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: count a drop's routing as work in flight, so its file is handed over too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hold the send until every file a conversation is owed has landed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: keep a panel mounted per conversation instead of handing its draft over Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the withdrawn chat whose composer was written in, not the empty one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the chat in front of the reader when both withdrawn composers were written in Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a retry's own run arguments when a turn elsewhere refuses it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: name panels apart across pools, and read a flow's inputs when its chat is built Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
85 lines
4.3 KiB
TypeScript
85 lines
4.3 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
import { Chat } from '@ai-sdk/react'
|
|
import { createWindmillChatTransport } from '../src/ai-sdk'
|
|
import { fetchMock, json, ndjson, sse, text } from './support'
|
|
|
|
const FLOW = 'f/chat/agent'
|
|
|
|
/** The AI SDK's own chunk processor consuming the transport, as `useChat` would. */
|
|
describe('AI SDK Chat over the Windmill transport', () => {
|
|
test('builds the assistant message parts and settles to ready', async () => {
|
|
let jobs = 0
|
|
const { fetch, calls } = fetchMock(
|
|
(c) =>
|
|
c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` ? text(`job-${++jobs}`) : undefined,
|
|
(c) =>
|
|
c.url.pathname.endsWith('/getupdate_sse/job-1')
|
|
? sse([
|
|
{
|
|
type: 'update',
|
|
new_result_stream: ndjson(
|
|
{ type: 'tool_call', call_id: 'c1', function_name: 'lookup' },
|
|
{ type: 'tool_call_arguments', call_id: 'c1', function_name: 'lookup', arguments: '{"q":1}' },
|
|
{ type: 'tool_result', call_id: 'c1', function_name: 'lookup', result: '42', success: true },
|
|
{ type: 'reasoning_token_delta', content: 'so ' },
|
|
{ type: 'token_delta', content: 'The answer ' },
|
|
{ type: 'token_delta', content: 'is 42' }
|
|
),
|
|
stream_offset: 6,
|
|
completed: true,
|
|
only_result: { output: 'The answer is 42', messages: [] }
|
|
}
|
|
])
|
|
: undefined,
|
|
(c) =>
|
|
c.url.pathname.endsWith('/getupdate_sse/job-2')
|
|
? sse([{ type: 'update', completed: true, only_result: { error: { message: 'boom' } } }])
|
|
: undefined,
|
|
(c) =>
|
|
c.url.pathname.endsWith('/completed/get_result_maybe/job-2') ? json({ completed: true, success: false }) : undefined
|
|
)
|
|
const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, token: 'tok', fetch })
|
|
const chat = new Chat({ id: 'e2e-chat', transport })
|
|
|
|
await chat.sendMessage({ text: 'what is it?' })
|
|
|
|
expect(chat.status).toBe('ready')
|
|
expect(chat.messages.map((m) => m.role)).toEqual(['user', 'assistant'])
|
|
const parts = chat.messages[1].parts
|
|
expect(parts.map((p) => p.type)).toEqual(['dynamic-tool', 'reasoning', 'text'])
|
|
expect(parts[0]).toMatchObject({ toolName: 'lookup', toolCallId: 'c1', state: 'output-available', input: { q: 1 }, output: 42 })
|
|
expect(parts[1]).toMatchObject({ type: 'reasoning', text: 'so ', state: 'done' })
|
|
expect(parts[2]).toMatchObject({ type: 'text', text: 'The answer is 42', state: 'done' })
|
|
// Both turns of the chat ran in the same Windmill conversation.
|
|
const memoryIds = calls.filter((c) => c.method === 'POST').map((c) => c.url.searchParams.get('memory_id'))
|
|
expect(memoryIds[0]).toBe(transport.conversationId('e2e-chat'))
|
|
|
|
await chat.sendMessage({ text: 'and now fail' })
|
|
expect(chat.status).toBe('error')
|
|
expect(chat.error?.message).toBe('boom')
|
|
expect(memoryIds.length === 1 || calls.filter((c) => c.method === 'POST')[1].url.searchParams.get('memory_id') === memoryIds[0]).toBe(true)
|
|
})
|
|
|
|
test('a text part cut by a lost stream is completed from the polled result', async () => {
|
|
let streams = 0
|
|
const { fetch } = fetchMock(
|
|
(c) => (c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` ? text('job-1') : undefined),
|
|
(c) =>
|
|
c.url.pathname.endsWith('/getupdate_sse/job-1')
|
|
? ++streams === 1
|
|
? sse([{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'The ans' }), stream_offset: 1 }])
|
|
: text('bad gateway', 502)
|
|
: undefined,
|
|
(c) =>
|
|
c.url.pathname.endsWith('/get_result_maybe/job-1')
|
|
? json({ completed: true, success: true, result: { output: 'The answer is 42', messages: [] } })
|
|
: undefined
|
|
)
|
|
const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, token: 'tok', fetch })
|
|
const chat = new Chat({ id: 'cut-chat', transport })
|
|
await chat.sendMessage({ text: 'what is it?' })
|
|
const texts = chat.messages[1].parts.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text)
|
|
expect(texts.join('')).toBe('The answer is 42')
|
|
}, 15000)
|
|
})
|