diff --git a/backend/.sqlx/query-1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051.json b/backend/.sqlx/query-318ed7a45d8326e3ecbc9727ee3812adaeeead8b39b007a32badadf67ec5ca17.json similarity index 69% rename from backend/.sqlx/query-1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051.json rename to backend/.sqlx/query-318ed7a45d8326e3ecbc9727ee3812adaeeead8b39b007a32badadf67ec5ca17.json index fb27bd9446..ae6ffd4e3c 100644 --- a/backend/.sqlx/query-1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051.json +++ b/backend/.sqlx/query-318ed7a45d8326e3ecbc9727ee3812adaeeead8b39b007a32badadf67ec5ca17.json @@ -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" } diff --git a/backend/.sqlx/query-b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1.json b/backend/.sqlx/query-55dbe12954489532644b91e67b2a9df7ede61e050d3e634b795aaf7f60b3b05d.json similarity index 63% rename from backend/.sqlx/query-b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1.json rename to backend/.sqlx/query-55dbe12954489532644b91e67b2a9df7ede61e050d3e634b795aaf7f60b3b05d.json index 1818efc0c0..2ae7033d0d 100644 --- a/backend/.sqlx/query-b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1.json +++ b/backend/.sqlx/query-55dbe12954489532644b91e67b2a9df7ede61e050d3e634b795aaf7f60b3b05d.json @@ -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" } diff --git a/backend/.sqlx/query-e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082.json b/backend/.sqlx/query-d6c65ce1d443d1e1783818d36c7eabd9633bbe5219bfd9683f336de19763cb58.json similarity index 62% rename from backend/.sqlx/query-e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082.json rename to backend/.sqlx/query-d6c65ce1d443d1e1783818d36c7eabd9633bbe5219bfd9683f336de19763cb58.json index a3374d6cdf..d3d76191f8 100644 --- a/backend/.sqlx/query-e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082.json +++ b/backend/.sqlx/query-d6c65ce1d443d1e1783818d36c7eabd9633bbe5219bfd9683f336de19763cb58.json @@ -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" } diff --git a/backend/migrations/20260916144653_flow_conversation_message_tool_call.down.sql b/backend/migrations/20260916144653_flow_conversation_message_tool_call.down.sql new file mode 100644 index 0000000000..56f20ba722 --- /dev/null +++ b/backend/migrations/20260916144653_flow_conversation_message_tool_call.down.sql @@ -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; diff --git a/backend/migrations/20260916144653_flow_conversation_message_tool_call.up.sql b/backend/migrations/20260916144653_flow_conversation_message_tool_call.up.sql new file mode 100644 index 0000000000..110ad73235 --- /dev/null +++ b/backend/migrations/20260916144653_flow_conversation_message_tool_call.up.sql @@ -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; diff --git a/backend/windmill-api-flow-conversations/src/lib.rs b/backend/windmill-api-flow-conversations/src/lib.rs index e85af5b83b..c9bab9bd85 100644 --- a/backend/windmill-api-flow-conversations/src/lib.rs +++ b/backend/windmill-api-flow-conversations/src/lib.rs @@ -36,6 +36,13 @@ pub struct FlowConversationMessage { pub created_seq: i64, pub step_name: Option, 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, + pub tool_result: Option, + /// The thinking of the iteration that produced this row. The agent job keeps the + /// turn's thinking as one string. + pub reasoning: Option, } #[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 diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 310e44ae1f..51702f387a 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -706,6 +706,7 @@ pub async fn handle_chat_conversation_messages( MessageType::User, None, true, + None, ) .await?; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2cb8a0e831..0b9593ead1 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-common/src/flow_conversations.rs b/backend/windmill-common/src/flow_conversations.rs index 21b1f56389..05278750c4 100644 --- a/backend/windmill-common/src/flow_conversations.rs +++ b/backend/windmill-common/src/flow_conversations.rs @@ -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, + pub tool_result: Option, + pub reasoning: Option, +} + /// 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?; diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 9c15229ba6..0661a98a4b 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -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, 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, ) { 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 { diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index 51a00e65a7..198e91750c 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -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, 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?; diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 156369e095..411b6480bd 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -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 { diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index ed543bd517..6c0dcaf279 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -2236,6 +2236,7 @@ async fn add_tool_message_to_conversation( MessageType::Assistant, None, success, + None, ) .await?; tx.commit().await?; diff --git a/chat-sdk/src/ai-sdk.ts b/chat-sdk/src/ai-sdk.ts index ba500446a3..527ece1e5c 100644 --- a/chat-sdk/src/ai-sdk.ts +++ b/chat-sdk/src/ai-sdk.ts @@ -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 @@ -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( diff --git a/chat-sdk/test/chat.test.ts b/chat-sdk/test/chat.test.ts index 1a5be8375d..dc518772f9 100644 --- a/chat-sdk/test/chat.test.ts +++ b/chat-sdk/test/chat.test.ts @@ -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(