1use std::path::{Path, PathBuf};
2
3use crate::events::{TaskOutcome, TaskOutcomeState};
4use mcp_utils::{
5 client::{CallToolError, SERVERNAME_DELIMITER},
6 display_meta::ToolResultMeta,
7};
8use rmcp::model::{CallToolRequestParams, CallToolResult, Task};
9use serde_json;
10
11use llm::{ToolCallError, ToolCallRequest, ToolCallResult};
12
13const TOOL_RESULT_MAX_BYTES: usize = 200_000;
16
17const SPILLOVER_PREVIEW_BYTES: usize = 10_000;
19
20pub fn tool_call_request_to_mcp(request: &ToolCallRequest) -> Result<CallToolRequestParams, String> {
22 let tool_name = request
23 .name
24 .split_once(SERVERNAME_DELIMITER)
25 .map_or_else(|| request.name.clone(), |(_, tool_name)| tool_name.to_string());
26
27 let arguments = serde_json::from_str::<serde_json::Value>(&request.arguments)
29 .map_err(|e| format!("Invalid tool arguments: {e}"))?
30 .as_object()
31 .cloned();
32
33 let mut params = CallToolRequestParams::new(tool_name);
34 if let Some(args) = arguments {
35 params = params.with_arguments(args);
36 }
37 Ok(params)
38}
39
40pub fn mcp_result_to_tool_call_result(
43 request: &ToolCallRequest,
44 mcp_result: rmcp::model::CallToolResult,
45) -> Result<(ToolCallResult, Option<ToolResultMeta>), ToolCallError> {
46 if mcp_result.is_error.unwrap_or(false) {
47 let error_msg = mcp_result.content.first().map_or_else(
48 || "Unknown error".to_string(),
49 |content| {
50 content.as_text().map_or_else(
51 || serde_json::to_string(content).unwrap_or_else(|_| "Unknown error".to_string()),
52 |text| text.text.clone(),
53 )
54 },
55 );
56 Err(ToolCallError {
57 id: request.id.clone(),
58 name: request.name.clone(),
59 arguments: Some(request.arguments.clone()),
60 error: format!("Tool execution error: {error_msg}"),
61 })
62 } else {
63 let (result_value, result_meta) = extract_result_and_meta(mcp_result.structured_content, &mcp_result.content);
64 let yaml = serde_yml::to_string(&result_value).unwrap_or_else(|_| result_value.to_string());
66 let result_str = maybe_spillover(&request.id, yaml, TOOL_RESULT_MAX_BYTES, &spillover_dir());
67 Ok((
68 ToolCallResult {
69 id: request.id.clone(),
70 name: request.name.clone(),
71 arguments: request.arguments.clone(),
72 result: result_str,
73 },
74 result_meta,
75 ))
76 }
77}
78
79pub fn map_task_result_to_outcome(
80 request: ToolCallRequest,
81 task: Task,
82 outcome: Result<CallToolResult, CallToolError>,
83) -> TaskOutcome {
84 let state = match convert_tool_result(&request, outcome) {
85 Ok((result, result_meta)) => TaskOutcomeState::Completed { result, result_meta },
86 Err(error) => TaskOutcomeState::Failed { error },
87 };
88 TaskOutcome { request, task_id: task.task_id, state }
89}
90
91pub fn convert_tool_result(
92 request: &ToolCallRequest,
93 outcome: Result<CallToolResult, CallToolError>,
94) -> Result<(ToolCallResult, Option<ToolResultMeta>), ToolCallError> {
95 outcome
96 .map_err(|error| ToolCallError::from_request(request, error.to_string()))
97 .and_then(|mcp_result| mcp_result_to_tool_call_result(request, mcp_result))
98}
99
100fn spillover_dir() -> PathBuf {
101 std::env::temp_dir().join("aether-tool-output")
102}
103
104fn maybe_spillover(tool_id: &str, result: String, max_bytes: usize, dir: &Path) -> String {
107 if result.len() <= max_bytes {
108 return result;
109 }
110
111 if let Err(e) = std::fs::create_dir_all(dir) {
112 tracing::warn!("Failed to create tool-output dir: {e}");
113 return result;
114 }
115
116 let file_path = dir.join(format!("{tool_id}.txt"));
117
118 if let Err(e) = std::fs::write(&file_path, &result) {
119 tracing::warn!("Failed to write spillover file: {e}");
120 return result;
121 }
122
123 let preview_end = result.floor_char_boundary(SPILLOVER_PREVIEW_BYTES);
124 let preview = &result[..preview_end];
125 let total_bytes = result.len();
126
127 format!(
128 "<preview>\n{preview}\n</preview>\n\n[Tool result too large ({total_bytes} bytes). Full output saved to {path}. Use grep, read, or tail to explore the full result.]",
129 path = file_path.display()
130 )
131}
132
133fn extract_result_and_meta(
134 structured_content: Option<serde_json::Value>,
135 content: &[rmcp::model::ContentBlock],
136) -> (serde_json::Value, Option<ToolResultMeta>) {
137 if let Some(mut val) = structured_content {
138 let result_meta = extract_result_meta(&mut val);
139 (val, result_meta)
140 } else {
141 let fallback = content.first().map_or_else(
142 || serde_json::Value::String("No result".to_string()),
143 |c| serde_json::to_value(c).unwrap_or(serde_json::Value::String("Serialization error".to_string())),
144 );
145 (fallback, None)
146 }
147}
148
149fn extract_result_meta(value: &mut serde_json::Value) -> Option<ToolResultMeta> {
150 let obj = value.as_object_mut()?;
151 let parsed: ToolResultMeta = {
152 let meta = obj.get("_meta")?.as_object()?;
153 serde_json::from_value(serde_json::Value::Object(meta.clone())).ok()?
154 };
155
156 let meta_empty = {
157 let meta = obj.get_mut("_meta")?.as_object_mut()?;
158 for key in ["display", "file_diff", "plan"] {
159 meta.remove(key);
160 }
161 meta.is_empty()
162 };
163
164 if meta_empty {
165 obj.remove("_meta");
166 }
167
168 Some(parsed)
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use mcp_utils::display_meta::PlanMetaStatus;
175 use rmcp::model::{CallToolResult as McpCallToolResult, ContentBlock};
176 use serde::Serialize;
177 use serde_json::json;
178
179 fn req() -> ToolCallRequest {
180 ToolCallRequest { id: "call_123".into(), name: "test_tool".into(), arguments: "{}".into() }
181 }
182
183 fn call_structured(structured: serde_json::Value) -> (ToolCallResult, Option<ToolResultMeta>) {
184 let mut mcp = McpCallToolResult::structured(structured);
185 mcp.content = vec![];
186 mcp_result_to_tool_call_result(&req(), mcp).unwrap()
187 }
188
189 fn extract_preview(result: &str) -> &str {
190 let start = result.find("<preview>\n").unwrap() + "<preview>\n".len();
191 let end = result.find("\n</preview>").unwrap();
192 &result[start..end]
193 }
194
195 #[test]
196 fn test_extracts_and_strips_meta() {
197 let structured = json!({
198 "status": "success", "file_path": "/test/file.rs",
199 "_meta": { "display": { "title": "Read file", "value": "file.rs, 50 lines" } }
200 });
201 let mut mcp = McpCallToolResult::structured(structured);
202 mcp.content = vec![ContentBlock::text("plain text fallback")];
203 let (result, meta) = mcp_result_to_tool_call_result(&req(), mcp).unwrap();
204
205 assert!(!result.result.contains("_meta"));
206 assert!(result.result.contains("success"));
207 let rm = meta.expect("meta should be present");
208 assert_eq!(rm.display.title, "Read file");
209 assert_eq!(rm.display.value, "file.rs, 50 lines");
210 assert!(rm.file_diff.is_none());
211 }
212
213 #[test]
214 fn test_extracts_meta_with_file_diff() {
215 let (result, meta) = call_structured(json!({
216 "status": "success",
217 "_meta": {
218 "display": { "title": "Edit file", "value": "main.rs" },
219 "file_diff": { "path": "/tmp/main.rs", "old_text": "old content", "new_text": "new content" }
220 }
221 }));
222 assert!(!result.result.contains("_meta"));
223 let rm = meta.expect("meta should be present");
224 assert_eq!(rm.display.title, "Edit file");
225 let fd = rm.file_diff.expect("file_diff should be present");
226 assert_eq!(fd.path, "/tmp/main.rs");
227 assert_eq!(fd.old_text.as_deref(), Some("old content"));
228 assert_eq!(fd.new_text, "new content");
229 }
230
231 #[test]
232 fn test_extracts_known_meta_and_preserves_unknown_meta_keys() {
233 let (result, meta) = call_structured(json!({
234 "status": "success",
235 "_meta": {
236 "display": { "title": "Edit file", "value": "main.rs" },
237 "file_diff": { "path": "/tmp/main.rs", "old_text": "old", "new_text": "new" },
238 "trace_id": "trace-123", "duration_ms": 18
239 }
240 }));
241 let rm = meta.expect("meta should be present");
242 assert_eq!(rm.display.title, "Edit file");
243 assert!(rm.file_diff.is_some());
244 for absent in ["display:", "file_diff:"] {
245 assert!(!result.result.contains(absent));
246 }
247 for present in ["trace_id:", "trace-123", "duration_ms:", "18"] {
248 assert!(result.result.contains(present));
249 }
250 }
251
252 #[test]
253 fn test_malformed_meta_returns_none() {
254 let (result, meta) = call_structured(json!({
255 "status": "success",
256 "_meta": { "display": "not a valid ToolDisplayMeta" }
257 }));
258 assert!(meta.is_none());
259 assert!(result.result.contains("not a valid ToolDisplayMeta"));
260 }
261
262 #[test]
263 fn test_no_meta_passes_through_unchanged() {
264 let (result, meta) = call_structured(json!({"status": "success", "data": "hello"}));
265 assert!(result.result.contains("success"));
266 assert!(result.result.contains("hello"));
267 assert!(meta.is_none());
268 }
269
270 #[test]
271 fn test_tool_call_result_falls_back_to_content() {
272 let mcp = McpCallToolResult::success(vec![ContentBlock::text("plain text result")]);
273 let (result, meta) = mcp_result_to_tool_call_result(&req(), mcp).unwrap();
274 assert!(result.result.contains("plain text result"));
275 assert!(meta.is_none());
276 }
277
278 #[test]
279 fn test_extracts_meta_with_plan() {
280 let (result, meta) = call_structured(json!({
281 "status": "success",
282 "_meta": {
283 "display": { "title": "Todo", "value": "Research AI agents" },
284 "plan": { "entries": [
285 { "content": "Research AI agents", "status": "in_progress" },
286 { "content": "Write tests", "status": "pending" }
287 ]}
288 }
289 }));
290 assert!(!result.result.contains("_meta"));
291 let rm = meta.expect("meta should be present");
292 assert_eq!(rm.display.title, "Todo");
293 let plan = rm.plan.expect("plan should be present");
294 assert_eq!(plan.entries.len(), 2);
295 assert_eq!(plan.entries[0].content, "Research AI agents");
296 assert_eq!(plan.entries[0].status, PlanMetaStatus::InProgress);
297 assert_eq!(plan.entries[1].status, PlanMetaStatus::Pending);
298 }
299
300 #[test]
303 fn test_meta_camel_case_serde_round_trip() {
304 #[derive(Serialize)]
305 #[serde(rename_all = "camelCase")]
306 struct GoodResult {
307 file_path: String,
308 total_lines: usize,
309 #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
310 _meta: Option<serde_json::Value>,
311 }
312
313 #[derive(Serialize)]
314 #[serde(rename_all = "camelCase")]
315 struct BrokenResult {
316 file_path: String,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 _meta: Option<serde_json::Value>,
319 }
320
321 let display_meta = json!({
322 "display": { "title": "Read file", "value": "file.rs, 50 lines" }
323 });
324
325 let good = serde_json::to_value(&GoodResult {
326 file_path: "/test/file.rs".into(),
327 total_lines: 50,
328 _meta: Some(display_meta.clone()),
329 })
330 .unwrap();
331 assert!(good.get("_meta").is_some(), "expected `_meta` key, got: {good}");
332 let (stripped, meta) = extract_result_and_meta(Some(good), &[]);
333 let rm = meta.expect("meta should be extracted");
334 assert_eq!(rm.display.title, "Read file");
335 assert_eq!(rm.display.value, "file.rs, 50 lines");
336 assert!(stripped.get("_meta").is_none());
337
338 let broken =
339 serde_json::to_value(&BrokenResult { file_path: "/test/file.rs".into(), _meta: Some(display_meta) })
340 .unwrap();
341 assert!(broken.get("_meta").is_none(), "should be mangled by camelCase");
342 assert!(broken.get("meta").is_some());
343 let (_, meta) = extract_result_and_meta(Some(broken), &[]);
344 assert!(meta.is_none(), "extraction should fail when _meta is mangled");
345 }
346
347 #[test]
348 fn test_tool_call_result_handles_text_error_without_sdk_debug_output() {
349 let mcp = McpCallToolResult::error(vec![ContentBlock::text("Error: file not found")]);
350 let err = mcp_result_to_tool_call_result(&req(), mcp).unwrap_err();
351 assert_eq!(err.error, "Tool execution error: Error: file not found");
352 }
353
354 #[test]
355 fn test_tool_call_result_serializes_non_text_error_content() {
356 let image = serde_json::from_value(serde_json::json!({
357 "type": "image",
358 "data": "aW1hZ2U=",
359 "mimeType": "image/png"
360 }))
361 .unwrap();
362 let mcp = McpCallToolResult::error(vec![image]);
363 let err = mcp_result_to_tool_call_result(&req(), mcp).unwrap_err();
364 assert_eq!(err.error, r#"Tool execution error: {"type":"image","data":"aW1hZ2U=","mimeType":"image/png"}"#);
365 }
366
367 #[test]
368 fn test_result_is_yaml_format() {
369 let (result, _) = call_structured(json!({
370 "status": "success",
371 "files": [{"name": "Cargo.toml", "path": "./Cargo.toml"}, {"name": "src", "path": "./src"}],
372 "totalCount": 2
373 }));
374 let r = &result.result;
375 for expected in ["status: success", "totalCount: 2", "- name:"] {
376 assert!(r.contains(expected), "expected '{expected}' in YAML, got: {r}");
377 }
378 assert!(!r.starts_with('{'), "expected YAML, not JSON: {r}");
379 }
380
381 #[test]
382 fn test_serde_yml_produces_yaml_not_json() {
383 let yaml = serde_yml::to_string(&json!({"key": "value"})).unwrap();
384 assert!(yaml.contains("key:") && yaml.contains("value") && !yaml.starts_with('{'));
385 }
386
387 #[test]
388 fn test_spillover_small_input_unchanged() {
389 let dir = tempfile::tempdir().unwrap();
390 let input = "hello world".to_string();
391 assert_eq!(maybe_spillover("id", input.clone(), 1000, dir.path()), input);
392 }
393
394 #[test]
395 fn test_spillover_large_input_writes_file() {
396 let dir = tempfile::tempdir().unwrap();
397 let large = "x".repeat(5000);
398 let result = maybe_spillover("test_large", large.clone(), 1000, dir.path());
399 for expected in ["<preview>", "</preview>", "Tool result too large", "5000 bytes", "test_large.txt"] {
400 assert!(result.contains(expected), "missing '{expected}' in: {result}");
401 }
402 let on_disk = std::fs::read_to_string(dir.path().join("test_large.txt")).unwrap();
403 assert_eq!(on_disk, large);
404 }
405
406 #[test]
407 fn test_spillover_preview_content() {
408 let dir = tempfile::tempdir().unwrap();
409 let large = format!("HEAD_{}{}", "z".repeat(SPILLOVER_PREVIEW_BYTES + 5000), "TAIL");
410 let result = maybe_spillover("id", large, 1000, dir.path());
411 assert!(result.contains("HEAD_"));
412 assert!(!result.contains("TAIL"));
413 }
414
415 #[test]
416 fn test_spillover_preserves_utf8_boundaries() {
417 let dir = tempfile::tempdir().unwrap();
418 let large = format!("{}{}", "\u{1F600}".repeat(300), "a".repeat(5000));
419 let result = maybe_spillover("id", large, 100, dir.path());
420 assert!(extract_preview(&result).chars().count() > 0);
421 }
422
423 #[test]
424 fn test_mcp_result_spills_large_output() {
425 let request =
426 ToolCallRequest { id: "spill_integration".into(), name: "big_tool".into(), arguments: "{}".into() };
427 let mut mcp = McpCallToolResult::structured(json!({"data": "x".repeat(TOOL_RESULT_MAX_BYTES + 1000)}));
428 mcp.content = vec![];
429 let (result, _) = mcp_result_to_tool_call_result(&request, mcp).unwrap();
430 for expected in ["<preview>", "Tool result too large", "spill_integration.txt"] {
431 assert!(result.result.contains(expected));
432 }
433 }
434}