Files
windmill/chat-sdk/test/runnable.test.ts
T
hugocasaandClaude Opus 5.5 c6a93a9b56 feat: chat with a saved agent from a raw app through an agent runnable (#11921)
* feat(chat-sdk): chat through a raw app's backend runnable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat-sdk): finish a lost stream and wait out a stopped turn in streamChatTurn

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat-sdk): show the answer when a stream is lost before a tool call

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(chat-sdk): send attachments through the raw app runnable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(chat-sdk): point sandboxed attachments at the runnable section

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: agent runnables for raw apps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: build agent runnables without the parquet feature, cache the agent check query

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: address review nits on the agent picker and the CLI schema doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: run an agent runnable once when it is given no memory_id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: accept previous_messages on agent runs, read-checking the files it quotes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: refuse an anonymous agent turn while one with the same memory still runs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show the agent's settings in the raw-app runnable panel, size its button like the row

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: offer an agent runnable memory_id or previous_messages after the agent's memory

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: take previous_messages in a memory-off agent's run form, type agent attachments as s3object

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: file an anonymous agent turn as a conversation, so its memory is collected and its turns serialized

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: own anonymous agent conversations under a fixed name that fits created_by

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: own anonymous agent conversations as anonymous and keep them out of conversation lists

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: point file_chat_turn at the anonymous conversation owner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: title the runnable picker after what it lists, in sentence case

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: start a chat with local history listing its stored conversations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: chat through a raw app's agent runnable with { wmill, runnable }

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: key an agent runnable chat's local history by its app as well

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: address review findings on agent runnables

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: offer memory_id to an agent runnable over an older auto memory

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: drop inherited static inputs from an agent runnable, show the memory-off note only when it is off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:01:09 +02:00

69 lines
3.1 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { TurnRunningError } from '../src/api'
import { createChat } from '../src/chat'
import { memoryStorage, ndjson } from './support'
describe('raw app agent runnable', () => {
test('chats through the runnable without a request of its own', async () => {
const calls: unknown[] = []
let refuse = false
const start = async (args: unknown) => {
// As the host relays a 409: the server's body behind the error's stack.
if (refuse) {
const body = { error: 'still answering', running_turn: { job_id: 'job-1', user_seq: 1 } }
throw new Error(`Error: ${JSON.stringify(body)}\n at x`)
}
calls.push(args)
return `job-${calls.length}`
}
const streamJob = async (jobId: string, onUpdate?: (u: { new_result_stream?: string }) => void) => {
const stream = ndjson({ type: 'token_delta', content: 'answer ' }, { type: 'token_delta', content: jobId })
onUpdate?.({ new_result_stream: stream.slice(0, 10) })
onUpdate?.({ new_result_stream: stream.slice(10) })
if (jobId === 'job-3') return { error: { name: 'Error', message: 'agent failed' } }
return { output: `answer ${jobId}`, messages: [] }
}
const storage = memoryStorage()
const wmill = { backendAsync: { chat: start, other: start }, streamJob }
const chat = createChat({
wmill,
runnable: 'chat',
storage,
fetch: () => Promise.reject(new Error('no request expected'))
})
expect(chat.getState().history).toBe('local')
await chat.sendMessage('hi')
await chat.sendMessage('again')
const conversationId = chat.getState().conversationId
expect(calls).toEqual([
{ user_message: 'hi', memory_id: conversationId },
{ user_message: 'again', memory_id: conversationId }
])
expect(chat.getState().messages.map((m) => m.content)).toEqual(['hi', 'answer job-1', 'again', 'answer job-2'])
await chat.sendMessage('third')
const messages = chat.getState().messages
const last = messages[messages.length - 1]
expect([last.content, last.success]).toEqual(['agent failed', false])
// A turn still running elsewhere takes the message back rather than failing a turn.
refuse = true
await expect(chat.sendMessage('fourth')).rejects.toBeInstanceOf(TurnRunningError)
expect(chat.getState().messages).toEqual(messages)
// Each runnable keeps its own history: another agent never sees this conversation.
expect(createChat({ wmill, runnable: 'chat', storage }).getState().conversations).toHaveLength(1)
expect(createChat({ wmill, runnable: 'other', storage }).getState().conversations).toEqual([])
// Nor does the same runnable name in another app, whose path the host puts in `ctx`.
const g = globalThis as { ctx?: unknown }
g.ctx = { path: 'u/admin/other_app' }
try {
expect(createChat({ wmill, runnable: 'chat', storage }).getState().conversations).toEqual([])
} finally {
delete g.ctx
}
// @ts-expect-error a name the app's module does not declare
createChat({ wmill, runnable: 'missing', storage })
})
})