Files
GuilhemandClaude Opus 5 a571117f3f feat: store mcp tool call, result and reasoning on flow conversation rows (#11176)
* feat: store mcp tool call, result and reasoning on flow conversation rows

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

* fix: keep the failure reason and web search citations on tool rows

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

* fix: persist a structured answer as its own row and keep reasoning-only rows from closing a turn

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

* fix: keep a failed Windmill tool's error on its conversation row

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

* fix: let a structured answer row claim its streamed thinking

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

* fix: scope a structured answer's claim to its own turn and document the row fields as stored

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

* fix: claim a textless assistant row only within the newest turn

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

* refactor: store the thinking that led to a tool call on the tool row

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

* fix: keep tool calls in stream order and scope a structured answer to its turn

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

* feat: store the files a user message carried as object-storage references on its row

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

* feat(chat-sdk): read a message's attachments and build their download URL

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

* feat(chat-sdk): carry a loaded user message's attachments in AI SDK metadata

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

* feat: store the model's call and what it got back on every tool row

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

* fix: pass no extras in the orphaned-conversation test's message insert

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

* fix(chat-sdk): keep a stored JSON null tool result instead of the row text

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

* fix: label a tool call the turn finished without as not finished

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

* fix: keep a structured answer's streamed call out of the did-not-finish label

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

* fix: write a turn's conversation rows in order and describe stored tool calls

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

* fix: await the image answer row like the agent loop's other rows

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

* fix(chat-sdk): place a row nothing streamed by its sequence, not at the end

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

* fix(chat-sdk): place only tool rows by sequence, keep a closing row last

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

* fix(flow chat): show a failed tool's error, and no Retry on a running or stopped turn

A failed tool card showed the row label in place of the error the row now
stores. The live tool result now reports failures, so a failed call briefly
flagged a running turn as failed; a stopped turn, whose last row is the
failed tool or the cancelled flow's failure, offered Retry too.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 15:35:18 +02:00

127 lines
4.8 KiB
TypeScript

import {
useExternalStoreRuntime,
type AppendMessage,
type AssistantRuntime,
type ExternalStoreAdapter,
type ThreadMessageLike
} from '@assistant-ui/react'
import { useEffect, useMemo } from 'react'
import { useWindmillChat } from './react'
import type { ChatMessage, ChatOptions } from './types'
import { parseJsonOr } from './utils'
/** One assistant turn: the assistant and tool rows between two user messages. */
export interface WindmillTurn {
id: string
role: 'user' | 'assistant' | 'system'
messages: ChatMessage[]
}
export interface WindmillRuntimeOptions extends ChatOptions {
/** Load the conversation list on mount so `ThreadListPrimitive` has something to show. Default true. */
threadList?: boolean
}
/**
* An assistant-ui runtime over a chat-mode flow, for `AssistantRuntimeProvider`.
* Conversations become threads, so the thread list primitives switch, create and
* delete Windmill conversations.
*/
export function useWindmillRuntime(options: WindmillRuntimeOptions): AssistantRuntime {
const chat = useWindmillChat(options)
const turns = useMemo(() => groupTurns(chat.messages), [chat.messages])
const withThreadList = options.threadList !== false
useEffect(() => {
if (withThreadList) chat.loadConversations().catch(() => {})
}, [chat.chat, withThreadList])
const adapter: ExternalStoreAdapter<WindmillTurn> = {
messages: turns,
isRunning: chat.status === 'submitted' || chat.status === 'streaming',
convertMessage: toThreadMessage,
onNew: async (message: AppendMessage) => {
if (message.role !== 'user') return
await chat.sendMessage(appendedText(message))
},
onCancel: async () => {
await chat.stop()
},
adapters: withThreadList
? {
threadList: {
threadId: chat.conversationId,
threads: chat.conversations.map((c) => ({ status: 'regular' as const, id: c.id, title: c.title })),
onSwitchToNewThread: () => chat.newConversation(),
onSwitchToThread: (id) => chat.selectConversation(id),
onDelete: (id) => chat.deleteConversation(id),
onRename: (id, title) => chat.renameConversation(id, title)
}
}
: undefined
}
return useExternalStoreRuntime<WindmillTurn>(adapter)
}
function appendedText(message: AppendMessage): string {
return message.content
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
.join('\n')
}
/** Folds the role-per-row message list into turns: one entry per user message, one per answer. */
export function groupTurns(messages: ChatMessage[]): WindmillTurn[] {
const turns: WindmillTurn[] = []
for (const m of messages) {
const last = turns[turns.length - 1]
if (m.role === 'user' || m.role === 'system' || !last || last.role !== 'assistant') {
turns.push({ id: m.id, role: m.role === 'tool' ? 'assistant' : m.role, messages: [m] })
} else {
last.messages.push(m)
}
}
return turns
}
/** A turn as assistant-ui content parts; the status reflects streaming and a failed flow. */
export function toThreadMessage(turn: WindmillTurn): ThreadMessageLike {
const first = turn.messages[0]
const createdAt = new Date(first.createdAt)
if (turn.role !== 'assistant') {
return { id: turn.id, role: turn.role, createdAt, content: [{ type: 'text', text: first.content }] }
}
const content: ThreadContentPart[] = []
for (const m of turn.messages) {
if (m.reasoning) content.push({ type: 'reasoning', text: m.reasoning })
if (m.role === 'tool') {
const args = parseJsonOr(m.tool?.arguments)
content.push({
type: 'tool-call',
toolCallId: m.tool?.callId ?? m.id,
toolName: m.tool?.name ?? 'tool',
args: (isJsonObject(args) ? args : args === undefined ? {} : { input: args }) as ToolCallArgs,
argsText: m.tool?.arguments ?? '',
result:
m.tool?.status === 'running' ? undefined : m.tool?.result !== undefined ? parseJsonOr(m.tool.result) : m.content,
isError: m.tool?.status === 'error'
})
continue
}
if (m.content) content.push({ type: 'text', text: m.content })
}
const last = turn.messages[turn.messages.length - 1]
const status: ThreadMessageLike['status'] = turn.messages.some((m) => m.pending)
? { type: 'running' }
: last.role === 'assistant' && !last.success
? { type: 'incomplete', reason: 'error', error: last.content }
: { type: 'complete', reason: 'stop' }
return { id: turn.id, role: 'assistant', createdAt, content, status }
}
type ThreadContentPart = Exclude<ThreadMessageLike['content'], string>[number]
type ToolCallArgs = Extract<ThreadContentPart, { type: 'tool-call' }>['args']
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}