feat: store mcp tool call, result and reasoning on flow conversation rows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-16 17:45:53 +02:00
co-authored by Claude Fable 5.1
parent a9ec0aec3a
commit d08a324d54
18 changed files with 312 additions and 35 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success\n FROM (\n SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success\n FROM flow_conversation_message\n WHERE conversation_id = $1\n ORDER BY created_seq DESC\n LIMIT $2 OFFSET $3\n ) AS messages\n ORDER BY created_seq ASC\n ",
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM flow_conversation_message\n WHERE conversation_id = $1\n AND created_seq > $2\n ORDER BY created_seq ASC\n LIMIT $3\n ",
"describe": {
"columns": [
{
@@ -58,6 +58,21 @@
"ordinal": 8,
"name": "success",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "tool_arguments",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "tool_result",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "reasoning",
"type_info": "Text"
}
],
"parameters": {
@@ -76,8 +91,11 @@
false,
false,
true,
false
false,
true,
true,
true
]
},
"hash": "1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051"
"hash": "318ed7a45d8326e3ecbc9727ee3812adaeeead8b39b007a32badadf67ec5ca17"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success)\n VALUES ($1, $2, $3, $4, $5, $6)",
"query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
"describe": {
"columns": [],
"parameters": {
@@ -21,10 +21,13 @@
"Text",
"Uuid",
"Varchar",
"Bool"
"Bool",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1"
"hash": "55dbe12954489532644b91e67b2a9df7ede61e050d3e634b795aaf7f60b3b05d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success\n FROM flow_conversation_message\n WHERE conversation_id = $1\n AND created_seq > $2\n ORDER BY created_seq ASC\n LIMIT $3\n ",
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM (\n SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM flow_conversation_message\n WHERE conversation_id = $1\n ORDER BY created_seq DESC\n LIMIT $2 OFFSET $3\n ) AS messages\n ORDER BY created_seq ASC\n ",
"describe": {
"columns": [
{
@@ -58,6 +58,21 @@
"ordinal": 8,
"name": "success",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "tool_arguments",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "tool_result",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "reasoning",
"type_info": "Text"
}
],
"parameters": {
@@ -76,8 +91,11 @@
false,
false,
true,
false
false,
true,
true,
true
]
},
"hash": "e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082"
"hash": "d6c65ce1d443d1e1783818d36c7eabd9633bbe5219bfd9683f336de19763cb58"
}
@@ -0,0 +1,3 @@
ALTER TABLE flow_conversation_message DROP COLUMN tool_arguments;
ALTER TABLE flow_conversation_message DROP COLUMN tool_result;
ALTER TABLE flow_conversation_message DROP COLUMN reasoning;
@@ -0,0 +1,10 @@
-- A chat is rebuilt from its rows without reading jobs. A tool row for a script or flow
-- names the tool's own job, which holds its call; an MCP tool and a provider-native tool
-- run inside the agent's job, whose result lists every call of the turn with nothing tying
-- one to a row. For those the row carries the call itself.
ALTER TABLE flow_conversation_message ADD COLUMN tool_arguments TEXT;
ALTER TABLE flow_conversation_message ADD COLUMN tool_result TEXT;
-- The thinking behind this row. The agent job keeps the turn's thinking as one string;
-- the rows keep it per iteration, next to the answer or tool call it led to.
ALTER TABLE flow_conversation_message ADD COLUMN reasoning TEXT;
@@ -36,6 +36,13 @@ pub struct FlowConversationMessage {
pub created_seq: i64,
pub step_name: Option<String>,
pub success: bool,
/// The call behind a tool row whose tool has no job of its own — an MCP tool, or a
/// provider-native one. Read back from the job otherwise, and null here.
pub tool_arguments: Option<String>,
pub tool_result: Option<String>,
/// The thinking of the iteration that produced this row. The agent job keeps the
/// turn's thinking as one string.
pub reasoning: Option<String>,
}
#[derive(Deserialize)]
@@ -178,7 +185,7 @@ async fn list_messages(
let messages = if let Some(after_seq) = query.after_seq {
sqlx::query_as!(
FlowConversationMessage,
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
FROM flow_conversation_message
WHERE conversation_id = $1
AND created_seq > $2
@@ -195,9 +202,9 @@ async fn list_messages(
// Fetch messages for this conversation, oldest first, but reverse the order of the messages for easy rendering on the frontend
sqlx::query_as!(
FlowConversationMessage,
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
FROM (
SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success
SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
FROM flow_conversation_message
WHERE conversation_id = $1
ORDER BY created_seq DESC
@@ -706,6 +706,7 @@ pub async fn handle_chat_conversation_messages(
MessageType::User,
None,
true,
None,
)
.await?;
+20
View File
@@ -28367,6 +28367,26 @@ components:
success:
type: boolean
description: Whether the message is a success
tool_arguments:
type: string
nullable: true
description: >-
The call, for a tool that runs inside the agent's job and so has none of its
own: an MCP tool, or a provider-native one such as web search. A Windmill tool
is a script or flow with its own job, and its call is read from there instead.
tool_result:
type: string
nullable: true
description: >-
What that same call returned, including the citations of a provider-native web
search, and what it failed with when it failed — the row's own text names the
tool rather than the reason. Null for a tool whose job holds the answer.
reasoning:
type: string
nullable: true
description: >-
The thinking behind this row, for the iteration that produced it. The agent
job's result keeps the turn's thinking as a single string.
EndpointTool:
type: object
@@ -73,6 +73,17 @@ pub async fn get_or_create_conversation_with_id(
Ok(conversation)
}
/// What a row carries beyond its text. A chat is rebuilt from its rows alone, without
/// reading jobs. An MCP or provider-native tool runs inside the agent's job, whose result
/// holds every call of the turn with nothing tying one to a row, and that job's
/// `reasoning` is one string for the whole turn where the rows keep it per iteration.
#[derive(Debug, Clone, Default)]
pub struct MessageExtras {
pub tool_arguments: Option<String>,
pub tool_result: Option<String>,
pub reasoning: Option<String>,
}
/// Add a message to a conversation using an existing transaction
/// If the conversation doesn't exist, logs a warning and returns Ok (no error thrown)
/// This allows memory_id to be used for agent memory without requiring a conversation
@@ -84,6 +95,7 @@ pub async fn add_message_to_conversation_tx(
message_type: MessageType,
step_name: Option<&str>,
success: bool,
extras: Option<&MessageExtras>,
) -> Result<()> {
// Check if conversation exists first
let conversation_exists = sqlx::query!(
@@ -104,14 +116,17 @@ pub async fn add_message_to_conversation_tx(
// Insert the message
sqlx::query!(
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success)
VALUES ($1, $2, $3, $4, $5, $6)",
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
conversation_id,
message_type as MessageType,
content,
job_id,
step_name,
success
success,
extras.and_then(|e| e.tool_arguments.as_deref()),
extras.and_then(|e| e.tool_result.as_deref()),
extras.and_then(|e| e.reasoning.as_deref())
)
.execute(&mut **tx)
.await?;
+55 -10
View File
@@ -33,7 +33,7 @@ use windmill_common::{
client::AuthedClient,
db::DB,
error::Error,
flow_conversations::MessageType,
flow_conversations::{MessageExtras, MessageType},
flow_status::AgentAction,
flows::FlowModuleValue,
worker::{to_raw_value, Connection},
@@ -235,9 +235,24 @@ async fn execute_mcp_tool_call(
update_flow_status_module_with_actions_success(ctx.db, parent_job, true).await?;
}
// Add tool message to conversation if chat_input_enabled
// An MCP tool runs inside the agent's job, whose result holds every call of the
// turn and nothing tying one of them to this row: same job id for all of them,
// no call id on the row. Kept here so the card shows this call — and the row
// names that job, so retention sweeps it with every other row of the turn.
let content = format!("Used {} tool", tool_call.function.name);
add_tool_message_to_chat(ctx, None, &content, true).await;
let agent_job_id = ctx.job.id;
add_tool_message_to_chat(
ctx,
Some(agent_job_id),
&content,
true,
Some(MessageExtras {
tool_arguments: Some(tool_call.function.arguments.clone()),
tool_result: Some(result_str),
..Default::default()
}),
)
.await;
}
Err(e) => {
let error_msg = format!("MCP tool error: {}", e);
@@ -271,8 +286,23 @@ async fn execute_mcp_tool_call(
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
}
// Add tool message to conversation if chat_input_enabled
add_tool_message_to_chat(ctx, None, &error_msg, false).await;
// Add tool message to conversation if chat_input_enabled. The row is worded from
// the tool, like every other tool row, and the error it failed with is its result
// — the one field a call that produced nothing else still has something to put in.
let agent_job_id = ctx.job.id;
let content = format!("Error executing {}", tool_name);
add_tool_message_to_chat(
ctx,
Some(agent_job_id),
&content,
false,
Some(MessageExtras {
tool_arguments: Some(tool_call.function.arguments.clone()),
tool_result: Some(error_msg.clone()),
..Default::default()
}),
)
.await;
}
}
@@ -680,8 +710,13 @@ async fn handle_tool_execution_error(
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
}
// Add tool message to conversation if chat_input_enabled (error case)
add_tool_message_to_chat(ctx, Some(job_id), &error_message, false).await;
// Add tool message to conversation if chat_input_enabled (error case). Worded from the
// tool like every other tool row; nothing is put on the row because the tool's own job
// holds it — `handle_non_flow_job_error` above completed that job with this error, and it
// was pushed with the arguments the step's input transforms produced rather than the raw
// ones the model supplied.
let content = format!("Error executing {}", tool_call.function.name);
add_tool_message_to_chat(ctx, Some(job_id), &content, false, None).await;
Ok(())
}
@@ -782,13 +817,15 @@ async fn handle_tool_execution_success(
..Default::default()
});
// Stream tool result (success case)
// The job ran; whether it ran successfully is `success`, and the row stored below is
// worded from it. The stream has to carry the same value, or the card the reader watches
// and the row that replaces it describe the same call differently.
if let Some(stream_event_processor) = ctx.stream_event_processor {
let tool_result_event = StreamingEvent::ToolResult {
call_id: tool_call.id.clone(),
function_name: tool_call.function.name.clone(),
result: tool_result,
success: true,
success,
};
stream_event_processor
.send(tool_result_event, final_events_str)
@@ -806,7 +843,7 @@ async fn handle_tool_execution_success(
format!("Error executing {}", tool_call.function.name)
};
add_tool_message_to_chat(ctx, Some(job_id), &content, success).await;
add_tool_message_to_chat(ctx, Some(job_id), &content, success, None).await;
Ok(())
}
@@ -814,9 +851,16 @@ async fn handle_tool_execution_success(
/// Add tool message to conversation if chat is enabled
async fn add_tool_message_to_chat(
ctx: &mut ToolExecutionContext<'_>,
// The job this row belongs to: the tool's own where it has one, else the agent's, which
// is the job it ran inside. Every row names one so that retention collects the whole
// turn — `delete_jobs` removes messages by `job_id = ANY(..)` (there is no FK on the
// column; `drop_v2_job_side_table_cascades` dropped it), and a row naming no job would
// survive every purge and leave a conversation that can never become empty.
tool_job_id: Option<Uuid>,
content: &str,
success: bool,
// Only for a tool with no job of its own; a Windmill tool's call is read from its job.
extras: Option<MessageExtras>,
) {
if ctx.omit_output_from_conversation {
return;
@@ -852,6 +896,7 @@ async fn add_tool_message_to_chat(
MessageType::Tool,
&step_name,
success,
extras.as_ref(),
)
.await
{
+3 -1
View File
@@ -13,7 +13,7 @@ use windmill_common::flows::FlowModuleValue;
use windmill_common::{
db::DB,
error::Error,
flow_conversations::{add_message_to_conversation_tx, MessageType},
flow_conversations::{add_message_to_conversation_tx, MessageExtras, MessageType},
flow_status::AgentAction,
flows::{InputTransform, Step},
jobs::JobKind,
@@ -209,6 +209,7 @@ pub async fn add_message_to_conversation(
message_type: MessageType,
step_name: &Option<String>,
success: bool,
extras: Option<&MessageExtras>,
) -> Result<(), Error> {
let mut tx = db.begin().await?;
add_message_to_conversation_tx(
@@ -219,6 +220,7 @@ pub async fn add_message_to_conversation(
message_type,
step_name.as_deref(),
success,
extras,
)
.await?;
tx.commit().await?;
+54 -1
View File
@@ -44,7 +44,7 @@ use windmill_common::{
client::AuthedClient,
db::DB,
error::{self, Error},
flow_conversations::MessageType,
flow_conversations::{MessageExtras, MessageType},
flow_status::AgentAction,
flows::{AgentTool, FlowModule, FlowModuleValue, InputTransform, ToolValue},
get_latest_hash_for_path,
@@ -1526,6 +1526,13 @@ pub async fn run_agent(
let db_clone = db.clone();
let message_content = "Used websearch tool successfully".to_string();
let step_name = step_name.clone();
// The search ran inside the provider's call, so this job's args
// describe the agent, not the search: its sources reach the row
// only if they are written here.
let extras = (!annotations.is_empty()).then(|| MessageExtras {
tool_result: serde_json::to_string(&annotations).ok(),
..Default::default()
});
tokio::spawn(async move {
if let Err(e) = add_message_to_conversation(
&db_clone,
@@ -1535,6 +1542,7 @@ pub async fn run_agent(
MessageType::Tool,
&step_name,
true,
extras.as_ref(),
)
.await
{
@@ -1578,6 +1586,11 @@ pub async fn run_agent(
let db_clone = db.clone();
let message_content = response_content.clone();
let step_name = step_name.clone();
// This iteration's thinking goes on the answer's row; the job
// result only keeps the turn's thinking as one string.
let extras = response_reasoning.clone().map(|reasoning| {
MessageExtras { reasoning: Some(reasoning), ..Default::default() }
});
// Spawn task because we do not need to wait for the result
tokio::spawn(async move {
@@ -1589,6 +1602,7 @@ pub async fn run_agent(
MessageType::Assistant,
&step_name,
true,
extras.as_ref(),
)
.await
{
@@ -1603,6 +1617,44 @@ pub async fn run_agent(
}
}
// An iteration that answered with tool calls has no message row to carry its
// thinking, and the next iteration's row holds only its own. Stored on a row
// of its own so a reader sees what led to the call.
if persist_output_to_conversation
&& response_content.as_deref().unwrap_or("").is_empty()
{
if let (Some(memory_id), Some(reasoning)) =
(memory_id, response_reasoning.clone())
{
let agent_job_id = job.id;
let db_clone = db.clone();
let step_name = step_name.clone();
tokio::spawn(async move {
if let Err(e) = add_message_to_conversation(
&db_clone,
&memory_id,
Some(agent_job_id),
"",
MessageType::Assistant,
&step_name,
true,
Some(&MessageExtras {
reasoning: Some(reasoning),
..Default::default()
}),
)
.await
{
tracing::warn!(
"Failed to add reasoning message to conversation {}: {}",
memory_id,
e
);
}
});
}
}
if tool_calls.is_empty() {
break;
} else if i == max_iterations - 1 {
@@ -1716,6 +1768,7 @@ pub async fn run_agent(
MessageType::Assistant,
&step_name,
true,
None,
)
.await
{
@@ -2236,6 +2236,7 @@ async fn add_tool_message_to_conversation(
MessageType::Assistant,
None,
success,
None,
)
.await?;
tx.commit().await?;
+22 -6
View File
@@ -1,5 +1,10 @@
import type { ChatTransport, UIMessage, UIMessageChunk, UIMessagePart } from 'ai'
import { WindmillApiError, WindmillChatApi, type WindmillChatApiOptions } from './api'
import {
WindmillApiError,
WindmillChatApi,
type FlowConversationMessage,
type WindmillChatApiOptions
} from './api'
import { followJob } from './follow'
import type { AgentStreamEvent } from './stream'
import type { ChatMessage, Conversation } from './types'
@@ -101,7 +106,8 @@ export function createWindmillChatTransport<UI_MESSAGE extends UIMessage = UIMes
stepName: row.step_name ?? undefined,
pending: false,
seq: row.created_seq,
tool: toolFromRowContent(row.message_type, row.content, row.success ?? true)
reasoning: row.reasoning ?? undefined,
tool: toolFromRow(row)
}))
) as UI_MESSAGE[]
},
@@ -123,10 +129,20 @@ export function createWindmillChatTransport<UI_MESSAGE extends UIMessage = UIMes
}
}
function toolFromRowContent(role: string, content: string, success: boolean): ChatMessage['tool'] {
if (role !== 'tool') return undefined
const name = /^Used (.+) tool$/.exec(content)?.[1] ?? /^Error executing (.+)$/.exec(content)?.[1]
return name ? { name, status: success ? 'success' : 'error' } : undefined
/** The call a stored tool row carries: its tool, named by the sentence the worker words
* every tool row from, and the arguments and result the row keeps when its job cannot be
* asked for them — for a failed tool, the result is what it failed with. */
function toolFromRow(row: FlowConversationMessage): ChatMessage['tool'] {
if (row.message_type !== 'tool') return undefined
const name =
/^Used (.+) tool$/.exec(row.content)?.[1] ?? /^Error executing (.+)$/.exec(row.content)?.[1]
if (!name) return undefined
return {
name,
status: (row.success ?? true) ? 'success' : 'error',
arguments: row.tool_arguments ?? undefined,
result: row.tool_result ?? undefined
}
}
/** Streams a job's answer as AI SDK chunks; resumes from `entry.offset` when the job is already running. */
+5
View File
@@ -40,6 +40,11 @@ export interface FlowConversationMessage {
created_seq: number
step_name?: string | null
success?: boolean
/** The call a tool row carries itself, for a tool whose job cannot be asked for it. */
tool_arguments?: string | null
tool_result?: string | null
/** The thinking behind an answer, which is streamed and stored nowhere else. */
reasoning?: string | null
}
export type JobUpdateEvent =
+12 -1
View File
@@ -725,7 +725,18 @@ function fromRow(row: FlowConversationMessage): ChatMessage {
stepName: row.step_name ?? undefined,
pending: false,
seq: row.created_seq,
tool: toolName ? { name: toolName, status: success ? 'success' : 'error' } : undefined
reasoning: row.reasoning ?? undefined,
// The call the row carries, which is all there is of it for a tool that ran inside the
// agent's job or failed before it had one of its own. For a failed tool the result is
// what it failed with, and the row's text names the tool rather than the reason.
tool: toolName
? {
name: toolName,
status: success ? 'success' : 'error',
arguments: row.tool_arguments ?? undefined,
result: row.tool_result ?? undefined
}
: undefined
}
}
+18 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'
import type { UIMessage, UIMessageChunk } from 'ai'
import { createWindmillChatTransport, toUIMessages } from '../src/ai-sdk'
import type { ChatMessage } from '../src/types'
import { fetchMock, json, ndjson, sse, text, type Route } from './support'
import { fetchMock, json, messageRow, ndjson, sse, text, type Route } from './support'
const FLOW = 'f/chat/agent'
const run: Route = (c) =>
@@ -141,6 +141,23 @@ describe('createWindmillChatTransport', () => {
expect(second[1]).toMatchObject({ errorText: 'ExecutionErr: boom' })
})
test('loads the call an MCP tool row carries and the reasoning behind an answer', async () => {
const { fetch } = fetchMock((c) =>
c.method === 'GET' && c.url.pathname.endsWith('/messages')
? json([
messageRow(1, 'user', 'hi'),
messageRow(2, 'tool', 'Used lookup tool', { job_id: 'agent-job', tool_arguments: '{"q":1}', tool_result: '42' }),
messageRow(3, 'assistant', 'The answer is 42', { reasoning: 'hmm' })
])
: undefined
)
const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, fetch })
const ui = await transport.loadMessages('c')
expect(ui.map((m) => m.parts.map((p) => p.type))).toEqual([['text'], ['dynamic-tool', 'reasoning', 'text']])
expect(ui[1].parts[0]).toMatchObject({ toolName: 'lookup', state: 'output-available', input: { q: 1 }, output: 42 })
expect(ui[1].parts[1]).toMatchObject({ type: 'reasoning', text: 'hmm' })
})
test('refuses attachments with a clear error', async () => {
const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, fetch: fetchMock().fetch })
await expect(
+32
View File
@@ -327,6 +327,38 @@ describe('createChat with server history', () => {
expect(messagesCall.headers.authorization).toBeUndefined()
})
test('a persisted row brings back its reasoning and the call an MCP tool row carries', async () => {
const { fetch } = fetchMock(
(c) =>
c.method === 'GET' && c.url.pathname === '/api/w/ws/flow_conversations/conv-1/messages'
? json([
messageRow(1, 'user', 'hi'),
messageRow(2, 'tool', 'Used lookup tool', {
job_id: 'agent-job',
tool_arguments: '{"q":1}',
tool_result: '42'
}),
messageRow(3, 'tool', 'Error executing lookup', {
job_id: 'agent-job',
success: false,
tool_arguments: '{"q":2}',
tool_result: 'MCP tool error: boom'
}),
messageRow(4, 'assistant', 'The answer is 42', { reasoning: 'hmm' }),
messageRow(5, 'assistant', 'Hello')
])
: undefined
)
const chat = createChat(options({ history: 'server' }, fetch))
await chat.selectConversation('conv-1')
const [, used, failed, answer, plain] = chat.getState().messages
expect(used.tool).toEqual({ name: 'lookup', status: 'success', arguments: '{"q":1}', result: '42' })
expect(failed.tool).toEqual({ name: 'lookup', status: 'error', arguments: '{"q":2}', result: 'MCP tool error: boom' })
expect(answer.reasoning).toBe('hmm')
expect(plain.reasoning).toBeUndefined()
})
test('keeps the streamed answer until its row lands, even when a tool row lands first', async () => {
let messageFetches = 0
const { fetch } = fetchMock(