1use serde_json::{Map, Value, json};
14
15use forge_primitives::{
16 BashOutputMetadata, ChunkContent, ToolCall, ToolCallContent, ToolCallLocation,
17 ToolCallUpdateFields, ToolOutputMetadata,
18};
19
20const CACHE_PREVIEW_LIMIT_BYTES: usize = 2048;
23
24fn preview_kilobyte_label() -> String {
25 let whole_kb = CACHE_PREVIEW_LIMIT_BYTES / 1024;
26 let remainder = CACHE_PREVIEW_LIMIT_BYTES % 1024;
27 if remainder == 0 {
28 format!("{whole_kb}KB")
29 } else {
30 let tenths = (remainder * 10) / 1024;
33 format!("{whole_kb}.{tenths}KB")
34 }
35}
36
37const TOOL_RESULT_TYPES: &[&str] = &[
40 "tool_result",
41 "tool_search_tool_result",
42 "web_fetch_tool_result",
43 "web_search_tool_result",
44 "code_execution_tool_result",
45 "bash_code_execution_tool_result",
46 "text_editor_code_execution_tool_result",
47 "mcp_tool_result",
48];
49
50pub fn is_tool_result_block_type(block_type: &str) -> bool {
51 TOOL_RESULT_TYPES.contains(&block_type)
52}
53
54fn normalize_tool_kind(name: &str) -> forge_primitives::ToolKind {
57 use forge_primitives::ToolKind;
58 match name {
59 "Bash" => ToolKind::Execute,
60 "Read" | "ReadMcpResource" => ToolKind::Read,
61 "Write" | "Edit" => ToolKind::Edit,
62 "Delete" => ToolKind::Delete,
63 "Move" => ToolKind::Move,
64 "Glob" | "Grep" => ToolKind::Search,
65 "WebFetch" => ToolKind::Fetch,
66 "ExitPlanMode" => ToolKind::SwitchMode,
67 _ => ToolKind::Think,
69 }
70}
71
72fn tool_title(name: &str, input: &Value) -> String {
76 let record = input.as_object();
77 let s =
78 |k: &str| -> &str { record.and_then(|r| r.get(k)).and_then(Value::as_str).unwrap_or("") };
79
80 match name {
81 "Bash" => {
82 let command = s("command");
83 if command.is_empty() { "Terminal".to_owned() } else { command.to_owned() }
84 }
85 "Glob" => {
86 let pattern = s("pattern");
87 let path = s("path");
88 match (pattern.is_empty(), path.is_empty()) {
89 (false, false) => format!("Glob {pattern} in {path}"),
90 (false, true) => format!("Glob {pattern}"),
91 (true, false) => format!("Glob {path}"),
92 (true, true) => name.to_owned(),
93 }
94 }
95 "WebFetch" => {
96 let url = s("url");
97 if url.is_empty() { name.to_owned() } else { format!("WebFetch {url}") }
98 }
99 "WebSearch" => {
100 let query = s("query");
101 if query.is_empty() { name.to_owned() } else { format!("WebSearch {query}") }
102 }
103 "Read" | "Write" | "Edit" | "Delete" => {
104 let file_path = s("file_path");
105 if file_path.is_empty() { name.to_owned() } else { format!("{name} {file_path}") }
106 }
107 "Move" => {
108 let from = s("from_path");
109 let to = s("to_path");
110 match (from.is_empty(), to.is_empty()) {
111 (false, false) => format!("Move {from} -> {to}"),
112 (false, true) => format!("Move {from}"),
113 (true, false) => format!("Move {to}"),
114 (true, true) => name.to_owned(),
115 }
116 }
117 "LSP" => {
118 let operation = s("operation");
119 let file_path = s("filePath");
120 match (operation.is_empty(), file_path.is_empty()) {
121 (false, false) => format!("LSP {operation} {file_path}"),
122 (false, true) => format!("LSP {operation}"),
123 (true, false) => format!("LSP {file_path}"),
124 (true, true) => name.to_owned(),
125 }
126 }
127 "PushNotification" => {
128 let message = s("message");
129 if message.is_empty() { name.to_owned() } else { format!("PushNotification {message}") }
130 }
131 "ReadMcpResource" => {
132 let uri = s("uri");
133 let server = s("server");
134 match (server.is_empty(), uri.is_empty()) {
135 (false, false) => format!("ReadMcpResource {server} {uri}"),
136 (true, false) => format!("ReadMcpResource {uri}"),
137 _ => name.to_owned(),
138 }
139 }
140 "ToolSearch" | "tool_search_tool_regex" | "tool_search_tool_bm25" => {
150 let query = s("query");
151 if query.is_empty() { "ToolSearch".to_owned() } else { format!("ToolSearch {query}") }
152 }
153 "web_search" => {
154 let query = s("query");
155 if query.is_empty() { "WebSearch".to_owned() } else { format!("WebSearch {query}") }
156 }
157 "Skill" => {
160 let skill = s("skill");
161 if skill.is_empty() {
162 name.to_owned()
163 } else {
164 let args = s("args");
165 if args.is_empty() {
166 format!("Skill {skill}")
167 } else {
168 format!("Skill {skill} {args}")
169 }
170 }
171 }
172 "web_fetch" => {
173 let url = s("url");
174 if url.is_empty() { "WebFetch".to_owned() } else { format!("WebFetch {url}") }
175 }
176 "advisor" => {
177 let query = s("query");
178 if query.is_empty() { "Advisor".to_owned() } else { format!("Advisor {query}") }
179 }
180 _ => name.to_owned(),
181 }
182}
183
184fn edit_diff_content(name: &str, input: &Value) -> Vec<ToolCallContent> {
188 let Some(record) = input.as_object() else {
189 return Vec::new();
190 };
191 let s = |k: &str| record.get(k).and_then(Value::as_str).unwrap_or("");
192 let file_path = s("file_path");
193 if file_path.is_empty() {
194 return Vec::new();
195 }
196
197 if name == "Edit" {
198 let old_text = s("old_string");
199 let new_text = s("new_string");
200 if old_text.is_empty() && new_text.is_empty() {
201 return Vec::new();
202 }
203 return vec![ToolCallContent::Diff {
204 new_path: file_path.to_owned(),
205 old: old_text.to_owned(),
206 new: new_text.to_owned(),
207 repository: None,
208 }];
209 }
210 if name == "Write" {
211 let new_text = s("content");
212 if new_text.is_empty() {
213 return Vec::new();
214 }
215 return vec![ToolCallContent::Diff {
216 new_path: file_path.to_owned(),
217 old: String::new(),
218 new: new_text.to_owned(),
219 repository: None,
220 }];
221 }
222 Vec::new()
223}
224
225pub fn create_tool_call(
229 tool_use_id: &str,
230 name: &str,
231 input: &Value,
232 parent_tool_use_id: Option<&str>,
233) -> ToolCall {
234 let file_path = input.as_object().and_then(|r| r.get("file_path")).and_then(Value::as_str);
235 let locations = file_path.map(|p| vec![ToolCallLocation::new(p)]).unwrap_or_default();
236 let meta = json!({
237 "claudeCode": {
238 "toolName": name,
239 "parentToolUseId": parent_tool_use_id,
240 }
241 });
242
243 ToolCall {
244 tool_call_id: tool_use_id.to_owned(),
245 title: tool_title(name, input),
246 kind: normalize_tool_kind(name),
247 status: forge_primitives::ToolCallStatus::Pending,
248 content: edit_diff_content(name, input),
249 raw_input: Some(input.clone()),
250 raw_output: None,
251 output_metadata: None,
252 task_metadata: None,
253 locations,
254 meta: Some(meta),
255 }
256}
257
258fn extract_text(value: &Value) -> String {
271 if let Some(s) = value.as_str() {
272 return s.to_owned();
273 }
274 if let Some(arr) = value.as_array() {
275 return arr
276 .iter()
277 .filter_map(|entry| {
278 if let Some(s) = entry.as_str() {
279 return Some(s.to_owned());
280 }
281 entry
282 .as_object()
283 .and_then(|r| r.get("text"))
284 .and_then(Value::as_str)
285 .map(str::to_owned)
286 })
287 .filter(|s| !s.is_empty())
288 .collect::<Vec<_>>()
289 .join("\n");
290 }
291 if let Some(obj) = value.as_object() {
292 for key in &["plan", "description", "summary", "message", "text"] {
293 if let Some(s) = obj.get(*key).and_then(Value::as_str) {
294 return s.to_owned();
295 }
296 }
297 }
298 String::new()
299}
300
301const PERSISTED_OUTPUT_OPEN_TAG: &str = "<persisted-output>";
302const PERSISTED_OUTPUT_CLOSE_TAG: &str = "</persisted-output>";
303
304fn persisted_output_inner_text(text: &str) -> Option<&str> {
305 let lower = text.to_ascii_lowercase();
306 let open = lower.find(PERSISTED_OUTPUT_OPEN_TAG)?;
307 let body_start = open + PERSISTED_OUTPUT_OPEN_TAG.len();
308 let close = lower[body_start..].find(PERSISTED_OUTPUT_CLOSE_TAG)? + body_start;
309 Some(&text[body_start..close])
310}
311
312fn persisted_output_first_line(text: &str) -> Option<String> {
313 let inner = persisted_output_inner_text(text)?;
314 let expected_preview = format!("preview (first {}):", preview_kilobyte_label().to_lowercase());
315 for line in inner.split(['\n', '\r']) {
316 let cleaned: String = line
317 .chars()
318 .skip_while(|c| matches!(*c, ' ' | '\t' | '|' | '\u{2502}' | '\u{2503}' | '\u{2551}'))
319 .collect();
320 let cleaned = cleaned.trim();
321 if cleaned.is_empty() {
322 continue;
323 }
324 if cleaned.eq_ignore_ascii_case(&expected_preview) {
325 continue;
326 }
327 return Some(cleaned.to_owned());
328 }
329 None
330}
331
332const USER_REJECTED_TOOL_USE_EXACT: &str = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
335const USER_REJECTED_TOOL_USE_PREFIX: &str = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:";
336const PERMISSION_DENIED_TOOL_USE_EXACT: &str = "Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). Try a different approach or report the limitation to complete your task.";
337const PERMISSION_DENIED_TOOL_USE_PREFIX: &str = "Permission for this tool use was denied. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). The user said:";
338
339fn sanitize_sdk_rejection_text(text: &str) -> String {
340 let normalized = text.trim();
341 if normalized == USER_REJECTED_TOOL_USE_EXACT
342 || normalized.starts_with(USER_REJECTED_TOOL_USE_PREFIX)
343 {
344 return "Cancelled by user.".to_owned();
345 }
346 if normalized == PERMISSION_DENIED_TOOL_USE_EXACT
347 || normalized.starts_with(PERMISSION_DENIED_TOOL_USE_PREFIX)
348 {
349 return "Permission denied.".to_owned();
350 }
351 text.to_owned()
352}
353
354fn normalize_tool_result_text(value: &Value, is_error: bool) -> String {
355 let text = extract_text(value);
356 if text.is_empty() {
357 return String::new();
358 }
359 let normalized = persisted_output_first_line(&text).unwrap_or(text);
360 if is_error { sanitize_sdk_rejection_text(&normalized) } else { normalized }
361}
362
363fn push_record(candidates: &mut Vec<Map<String, Value>>, value: &Value) {
366 if let Some(r) = value.as_object() {
367 candidates.push(r.clone());
368 }
369}
370
371fn push_records(candidates: &mut Vec<Map<String, Value>>, value: &Value) {
372 if let Some(arr) = value.as_array() {
373 for entry in arr {
374 push_record(candidates, entry);
375 }
376 return;
377 }
378 push_record(candidates, value);
379}
380
381fn push_nested_records(candidates: &mut Vec<Map<String, Value>>, value: &Value) {
382 if let Some(arr) = value.as_array() {
383 for entry in arr {
384 push_nested_records(candidates, entry);
385 }
386 return;
387 }
388 let Some(record) = value.as_object() else {
389 return;
390 };
391 if let Some(v) = record.get("result") {
392 push_records(candidates, v);
393 }
394 if let Some(v) = record.get("data") {
395 push_records(candidates, v);
396 }
397 if let Some(v) = record.get("content") {
398 push_records(candidates, v);
399 }
400}
401
402fn result_record_candidates(
403 raw_result: Option<&Value>,
404 raw_content: Option<&Value>,
405) -> Vec<Map<String, Value>> {
406 let mut out = Vec::new();
407 if let Some(v) = raw_result {
408 push_records(&mut out, v);
409 push_nested_records(&mut out, v);
410 }
411 if let Some(v) = raw_content {
412 push_records(&mut out, v);
413 push_nested_records(&mut out, v);
414 }
415 out
416}
417
418fn find_bash_result_record(
421 raw_result: Option<&Value>,
422 raw_content: Option<&Value>,
423) -> Option<Map<String, Value>> {
424 result_record_candidates(raw_result, raw_content).into_iter().find(|c| {
425 c.contains_key("stdout")
426 || c.contains_key("stderr")
427 || c.contains_key("backgroundTaskId")
428 || c.contains_key("backgroundedByUser")
429 || c.contains_key("assistantAutoBackgrounded")
430 })
431}
432
433fn bash_background_message(record: &Map<String, Value>) -> String {
434 let Some(task_id) = record.get("backgroundTaskId").and_then(Value::as_str) else {
435 return String::new();
436 };
437 if task_id.is_empty() {
438 return String::new();
439 }
440 if record.get("assistantAutoBackgrounded").and_then(Value::as_bool) == Some(true) {
441 return format!("Command was auto-backgrounded by assistant mode with ID: {task_id}.");
442 }
443 if record.get("backgroundedByUser").and_then(Value::as_bool) == Some(true) {
444 return format!("Command was backgrounded by user with ID: {task_id}.");
445 }
446 format!("Command is running in background with ID: {task_id}.")
447}
448
449fn result_indicates_background(raw_result: Option<&Value>, raw_content: Option<&Value>) -> bool {
453 result_record_candidates(raw_result, raw_content).iter().any(|c| {
454 c.get("backgroundTaskId").and_then(Value::as_str).is_some_and(|s| !s.is_empty())
455 || c.get("assistantAutoBackgrounded").and_then(Value::as_bool) == Some(true)
456 || c.get("backgroundedByUser").and_then(Value::as_bool) == Some(true)
457 })
458}
459
460fn build_bash_display_output(record: &Map<String, Value>) -> String {
461 let mut segments = Vec::new();
462 if let Some(s) = record.get("stdout").and_then(Value::as_str)
463 && !s.is_empty()
464 {
465 segments.push(s.to_owned());
466 }
467 if let Some(s) = record.get("stderr").and_then(Value::as_str)
468 && !s.is_empty()
469 {
470 segments.push(s.to_owned());
471 }
472 if record.get("interrupted").and_then(Value::as_bool) == Some(true) {
473 segments.push("Command was aborted before completion.".to_owned());
474 }
475 let bg = bash_background_message(record);
476 if !bg.is_empty() {
477 segments.push(bg);
478 }
479 segments.join("\n")
480}
481
482fn file_unchanged_result_text(raw_result: Option<&Value>, raw_content: Option<&Value>) -> String {
483 for candidate in result_record_candidates(raw_result, raw_content) {
484 if candidate.get("type").and_then(Value::as_str) != Some("file_unchanged") {
485 continue;
486 }
487 if let Some(file_record) = candidate.get("file").and_then(Value::as_object)
488 && let Some(file_path) = file_record.get("filePath").and_then(Value::as_str)
489 {
490 let trimmed = file_path.trim();
491 if !trimmed.is_empty() {
492 return format!("File unchanged: {trimmed}");
493 }
494 }
495 }
496 String::new()
497}
498
499fn agent_title_from_agent_output(
500 raw_result: Option<&Value>,
501 raw_content: Option<&Value>,
502) -> String {
503 for candidate in result_record_candidates(raw_result, raw_content) {
504 if let Some(agent_type) = candidate.get("agentType").and_then(Value::as_str) {
505 let trimmed = agent_type.trim();
506 if !trimmed.is_empty() {
507 return trimmed.to_owned();
508 }
509 }
510 }
511 String::new()
512}
513
514fn write_diff_from_input(raw_input: Option<&Value>) -> Vec<ToolCallContent> {
515 let Some(record) = raw_input.and_then(Value::as_object) else {
516 return Vec::new();
517 };
518 let file_path = record.get("file_path").and_then(Value::as_str).unwrap_or("");
519 let content = record.get("content").and_then(Value::as_str).unwrap_or("");
520 if file_path.is_empty() || content.is_empty() {
521 return Vec::new();
522 }
523 vec![ToolCallContent::Diff {
524 new_path: file_path.to_owned(),
525 old: String::new(),
526 new: content.to_owned(),
527 repository: None,
528 }]
529}
530
531fn edit_diff_from_input(raw_input: Option<&Value>) -> Vec<ToolCallContent> {
532 let Some(record) = raw_input.and_then(Value::as_object) else {
533 return Vec::new();
534 };
535 let file_path = record.get("file_path").and_then(Value::as_str).unwrap_or("");
536 let old_text = record
537 .get("old_string")
538 .or_else(|| record.get("oldString"))
539 .and_then(Value::as_str)
540 .unwrap_or("");
541 let new_text = record
542 .get("new_string")
543 .or_else(|| record.get("newString"))
544 .and_then(Value::as_str)
545 .unwrap_or("");
546 if file_path.is_empty() || (old_text.is_empty() && new_text.is_empty()) {
547 return Vec::new();
548 }
549 vec![ToolCallContent::Diff {
550 new_path: file_path.to_owned(),
551 old: old_text.to_owned(),
552 new: new_text.to_owned(),
553 repository: None,
554 }]
555}
556
557fn write_diff_from_result(raw_content: Option<&Value>) -> Vec<ToolCallContent> {
558 let candidates: Vec<&Value> = match raw_content {
559 Some(v) => v.as_array().map_or_else(|| vec![v], |a| a.iter().collect()),
560 None => Vec::new(),
561 };
562 for candidate in candidates {
563 let Some(record) = candidate.as_object() else {
564 continue;
565 };
566 let file_path = record
567 .get("filePath")
568 .or_else(|| record.get("file_path"))
569 .and_then(Value::as_str)
570 .unwrap_or("");
571 let content = record.get("content").and_then(Value::as_str).unwrap_or("");
572 let original_raw = record.get("originalFile").or_else(|| record.get("original_file"));
573 let git_diff = record.get("gitDiff").and_then(Value::as_object);
574 let repository = git_diff
575 .and_then(|g| g.get("repository"))
576 .and_then(Value::as_str)
577 .map(str::trim)
578 .filter(|s| !s.is_empty())
579 .map(str::to_owned);
580
581 if file_path.is_empty() || content.is_empty() || original_raw.is_none() {
582 continue;
583 }
584 let original = original_raw.and_then(Value::as_str).map_or_else(String::new, str::to_owned);
585 return vec![ToolCallContent::Diff {
586 new_path: file_path.to_owned(),
587 old: original,
588 new: content.to_owned(),
589 repository,
590 }];
591 }
592 Vec::new()
593}
594
595fn edit_diff_from_result(
596 raw_result: Option<&Value>,
597 raw_input: Option<&Value>,
598) -> Vec<ToolCallContent> {
599 let Some(input_record) = raw_input.and_then(Value::as_object) else {
600 return edit_diff_from_input(raw_input);
601 };
602 let file_path = input_record.get("file_path").and_then(Value::as_str).unwrap_or("");
603 let old_text = input_record
604 .get("old_string")
605 .or_else(|| input_record.get("oldString"))
606 .and_then(Value::as_str)
607 .unwrap_or("");
608 let new_text = input_record
609 .get("new_string")
610 .or_else(|| input_record.get("newString"))
611 .and_then(Value::as_str)
612 .unwrap_or("");
613 if file_path.is_empty() || (old_text.is_empty() && new_text.is_empty()) {
614 return Vec::new();
615 }
616 for candidate in result_record_candidates(raw_result, None) {
617 let candidate_path = candidate
618 .get("filePath")
619 .or_else(|| candidate.get("file_path"))
620 .and_then(Value::as_str)
621 .unwrap_or("");
622 let git_diff = candidate.get("gitDiff").and_then(Value::as_object);
623 if candidate_path.is_empty() && git_diff.is_none() {
624 continue;
625 }
626 if !candidate_path.is_empty() && candidate_path != file_path {
627 continue;
628 }
629 let repository = git_diff
630 .and_then(|g| g.get("repository"))
631 .and_then(Value::as_str)
632 .map(str::trim)
633 .filter(|s| !s.is_empty())
634 .map(str::to_owned);
635 return vec![ToolCallContent::Diff {
636 new_path: file_path.to_owned(),
637 old: old_text.to_owned(),
638 new: new_text.to_owned(),
639 repository,
640 }];
641 }
642 edit_diff_from_input(raw_input)
643}
644
645fn parse_json_candidate(value: Option<&Value>) -> Option<Value> {
646 let text = value.map_or_else(String::new, |v| {
647 if let Some(s) = v.as_str() { s.to_owned() } else { extract_text(v) }
648 });
649 let trimmed = text.trim();
650 if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
651 return None;
652 }
653 serde_json::from_str(trimmed)
654 .inspect_err(|e| {
655 tracing::debug!(
656 target: crate::logging::targets::TOOLING,
657 error = %e,
658 raw_prefix = %trimmed.chars().take(120).collect::<String>(),
659 "tool output looked like JSON but failed to parse; falling back to plain-text rendering",
660 );
661 })
662 .ok()
663}
664
665fn push_structured_record_candidates(candidates: &mut Vec<Map<String, Value>>, value: &Value) {
666 let Some(record) = value.as_object() else {
667 return;
668 };
669 candidates.push(record.clone());
670 for key in ["result", "data", "content"] {
671 if let Some(nested) = record.get(key).and_then(Value::as_object) {
672 candidates.push(nested.clone());
673 }
674 }
675}
676
677fn mcp_resource_content_from_result(
678 raw_result: Option<&Value>,
679 raw_content: Option<&Value>,
680) -> Vec<ToolCallContent> {
681 let mut candidates: Vec<Map<String, Value>> = Vec::new();
682 if let Some(v) = raw_result {
683 push_structured_record_candidates(&mut candidates, v);
684 }
685 if let Some(v) = raw_content {
686 push_structured_record_candidates(&mut candidates, v);
687 }
688 if let Some(v) = parse_json_candidate(raw_result) {
689 push_structured_record_candidates(&mut candidates, &v);
690 }
691 if let Some(v) = parse_json_candidate(raw_content) {
692 push_structured_record_candidates(&mut candidates, &v);
693 }
694
695 for candidate in candidates {
696 let Some(contents) = candidate.get("contents").and_then(Value::as_array) else {
697 continue;
698 };
699 if contents.is_empty() {
700 continue;
701 }
702 let mut mapped: Vec<ToolCallContent> = Vec::new();
703 for entry in contents {
704 let Some(record) = entry.as_object() else {
705 continue;
706 };
707 let Some(uri) = record.get("uri").and_then(Value::as_str) else {
708 continue;
709 };
710 if uri.is_empty() {
711 continue;
712 }
713 let text = record
714 .get("text")
715 .and_then(Value::as_str)
716 .filter(|s| !s.is_empty())
717 .map(str::to_owned);
718 let mime_type = record
719 .get("mimeType")
720 .and_then(Value::as_str)
721 .map(str::trim)
722 .filter(|s| !s.is_empty())
723 .map(str::to_owned);
724 let blob_saved_to = record
725 .get("blobSavedTo")
726 .and_then(Value::as_str)
727 .map(str::trim)
728 .filter(|s| !s.is_empty())
729 .map(str::to_owned);
730 if text.is_none() && blob_saved_to.is_none() {
731 continue;
732 }
733 mapped.push(ToolCallContent::McpResource {
734 uri: uri.to_owned(),
735 mime_type,
736 text,
737 blob_saved_to,
738 });
739 }
740 if !mapped.is_empty() {
741 return mapped;
742 }
743 }
744 Vec::new()
745}
746
747fn extract_tool_output_metadata(
750 tool_name: &str,
751 raw_result: Option<&Value>,
752 raw_content: Option<&Value>,
753) -> Option<ToolOutputMetadata> {
754 let candidates = result_record_candidates(raw_result, raw_content);
755
756 if tool_name == "Bash" {
757 for candidate in &candidates {
758 if let Some(b) = candidate.get("assistantAutoBackgrounded").and_then(Value::as_bool) {
759 return Some(ToolOutputMetadata {
760 bash: Some(BashOutputMetadata { assistant_auto_backgrounded: Some(b) }),
761 });
762 }
763 }
764 return None;
765 }
766
767 None
768}
769
770fn resolve_tool_name(base: Option<&ToolCall>) -> String {
771 let Some(meta) = base.and_then(|b| b.meta.as_ref()) else {
772 return String::new();
773 };
774 let Some(claude_code) = meta.get("claudeCode").and_then(Value::as_object) else {
775 return String::new();
776 };
777 claude_code.get("toolName").and_then(Value::as_str).unwrap_or("").to_owned()
778}
779
780pub fn build_tool_result_fields(
786 is_error: bool,
787 raw_content: Option<&Value>,
788 base: Option<&ToolCall>,
789 raw_result: Option<&Value>,
790) -> ToolCallUpdateFields {
791 let tool_name = resolve_tool_name(base);
792 let mut fields = ToolCallUpdateFields {
793 status: Some(if is_error {
794 forge_primitives::ToolCallStatus::Failed
795 } else {
796 forge_primitives::ToolCallStatus::Completed
797 }),
798 ..Default::default()
799 };
800
801 let file_unchanged_text = if !is_error && tool_name == "Read" {
803 file_unchanged_result_text(raw_result, raw_content)
804 } else {
805 String::new()
806 };
807 if !file_unchanged_text.is_empty() {
808 fields.raw_output = Some(file_unchanged_text.clone());
809 fields.content = Some(vec![ToolCallContent::Content {
810 content: ChunkContent::Text { text: file_unchanged_text },
811 }]);
812 return fields;
813 }
814
815 if !is_error && tool_name == "Agent" {
818 let agent_title = agent_title_from_agent_output(raw_result, raw_content);
819 if !agent_title.is_empty() {
820 fields.title = Some(agent_title);
821 }
822 if result_indicates_background(raw_result, raw_content) {
823 fields.status = None;
824 }
825 }
826
827 let bash_record =
829 if tool_name == "Bash" { find_bash_result_record(raw_result, raw_content) } else { None };
830 let normalized_raw_output =
831 raw_content.map(|v| normalize_tool_result_text(v, is_error)).unwrap_or_default();
832 let raw_output = if let Some(record) = bash_record.as_ref() {
833 build_bash_display_output(record)
834 } else if !normalized_raw_output.is_empty() {
835 normalized_raw_output
836 } else {
837 raw_content.and_then(Value::as_str).map(str::to_owned).unwrap_or_default()
844 };
845 if !raw_output.is_empty() {
846 fields.raw_output = Some(raw_output.clone());
847 }
848
849 if let Some(meta) = extract_tool_output_metadata(&tool_name, raw_result, raw_content) {
851 fields.output_metadata = Some(meta);
852 }
853
854 if !is_error && tool_name == "Write" {
856 let structured = write_diff_from_result(raw_content);
857 if !structured.is_empty() {
858 fields.content = Some(structured);
859 return fields;
860 }
861 let from_input = write_diff_from_input(base.and_then(|b| b.raw_input.as_ref()));
862 if !from_input.is_empty() {
863 fields.content = Some(from_input);
864 return fields;
865 }
866 }
867
868 if !is_error && tool_name == "Edit" {
870 let structured = edit_diff_from_result(raw_result, base.and_then(|b| b.raw_input.as_ref()));
871 if !structured.is_empty() {
872 fields.content = Some(structured);
873 return fields;
874 }
875 if base.is_some_and(|b| b.content.iter().any(|c| matches!(c, ToolCallContent::Diff { .. })))
876 {
877 return fields;
878 }
879 }
880
881 if !is_error && tool_name == "ReadMcpResource" {
883 let structured = mcp_resource_content_from_result(raw_result, raw_content);
884 if !structured.is_empty() {
885 fields.content = Some(structured);
886 return fields;
887 }
888 }
889
890 if !is_error && tool_name == "ToolSearch" {
897 let matches = tool_search_matches_from_content(raw_content);
898 if !matches.is_empty() {
899 let summary = format!("Found {}", matches.join(", "));
900 fields.raw_output = Some(summary.clone());
901 fields.content = Some(vec![ToolCallContent::Content {
902 content: ChunkContent::Text { text: summary },
903 }]);
904 return fields;
905 }
906 }
907
908 if !raw_output.is_empty() {
910 fields.content = Some(vec![ToolCallContent::Content {
911 content: ChunkContent::Text { text: raw_output },
912 }]);
913 }
914 fields
915}
916
917fn tool_search_matches_from_content(raw_content: Option<&Value>) -> Vec<String> {
924 let Some(arr) = raw_content.and_then(Value::as_array) else {
925 return Vec::new();
926 };
927 let mut out = Vec::with_capacity(arr.len());
928 for entry in arr {
929 let Some(record) = entry.as_object() else { continue };
930 if record.get("type").and_then(Value::as_str) != Some("tool_reference") {
931 continue;
932 }
933 if let Some(name) =
934 record.get("tool_name").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())
935 {
936 out.push(name.to_owned());
937 }
938 }
939 out
940}
941
942pub struct UnwrappedToolResult {
946 pub is_error: bool,
947 pub content: Value,
948}
949
950pub fn unwrap_tool_use_result(raw_result: &Value) -> UnwrappedToolResult {
951 let Some(record) = raw_result.as_object() else {
952 return UnwrappedToolResult { is_error: false, content: raw_result.clone() };
953 };
954 let is_error = record.get("is_error").and_then(Value::as_bool).unwrap_or(false)
955 || record.get("error").and_then(Value::as_bool).unwrap_or(false);
956 if let Some(c) = record.get("content") {
957 return UnwrappedToolResult { is_error, content: c.clone() };
958 }
959 if let Some(c) = record.get("result") {
960 return UnwrappedToolResult { is_error, content: c.clone() };
961 }
962 if let Some(c) = record.get("text") {
963 return UnwrappedToolResult { is_error, content: c.clone() };
964 }
965 UnwrappedToolResult { is_error, content: raw_result.clone() }
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971 use serde_json::json;
972
973 fn make_base(name: &str, input: &serde_json::Value) -> ToolCall {
974 create_tool_call("tu_1", name, input, None)
975 }
976
977 #[test]
978 fn create_tool_call_titles_per_tool() {
979 use forge_primitives::ToolKind;
980 let bash = create_tool_call("tu1", "Bash", &json!({"command": "ls"}), None);
981 assert_eq!(bash.title, "ls");
982 assert_eq!(bash.kind, ToolKind::Execute);
983
984 let read = create_tool_call("tu2", "Read", &json!({"file_path": "/x"}), None);
985 assert_eq!(read.title, "Read /x");
986 assert_eq!(read.kind, ToolKind::Read);
987
988 let glob =
989 create_tool_call("tu3", "Glob", &json!({"pattern": "*.rs", "path": "src"}), None);
990 assert_eq!(glob.title, "Glob *.rs in src");
991 assert_eq!(glob.kind, ToolKind::Search);
992
993 let task = create_tool_call("tu4", "Task", &json!({}), None);
994 assert_eq!(task.kind, ToolKind::Think);
995 }
996
997 #[test]
1004 fn create_tool_call_titles_server_tools_carry_query_or_url() {
1005 let regex = create_tool_call(
1006 "stu1",
1007 "tool_search_tool_regex",
1008 &json!({"query": "select:CronList"}),
1009 None,
1010 );
1011 assert_eq!(regex.title, "ToolSearch select:CronList");
1012
1013 let bm25 = create_tool_call(
1014 "stu2",
1015 "tool_search_tool_bm25",
1016 &json!({"query": "notebook jupyter"}),
1017 None,
1018 );
1019 assert_eq!(bm25.title, "ToolSearch notebook jupyter");
1020
1021 let web_search =
1022 create_tool_call("stu3", "web_search", &json!({"query": "rust async"}), None);
1023 assert_eq!(web_search.title, "WebSearch rust async");
1024
1025 let web_fetch =
1026 create_tool_call("stu4", "web_fetch", &json!({"url": "https://example.com"}), None);
1027 assert_eq!(web_fetch.title, "WebFetch https://example.com");
1028
1029 let advisor =
1030 create_tool_call("stu5", "advisor", &json!({"query": "how to handle X"}), None);
1031 assert_eq!(advisor.title, "Advisor how to handle X");
1032 }
1033
1034 #[test]
1040 fn create_tool_call_titles_skill_carries_skill_name_and_args() {
1041 let with_args = create_tool_call(
1042 "tu_s1",
1043 "Skill",
1044 &json!({"skill": "pr-review-loop", "args": "661"}),
1045 None,
1046 );
1047 assert_eq!(with_args.title, "Skill pr-review-loop 661");
1048
1049 let name_only = create_tool_call("tu_s2", "Skill", &json!({"skill": "code-review"}), None);
1050 assert_eq!(name_only.title, "Skill code-review");
1051
1052 let empty = create_tool_call("tu_s3", "Skill", &json!({}), None);
1053 assert_eq!(empty.title, "Skill");
1054 assert!(
1055 !empty.title.ends_with(' '),
1056 "empty-input title must not end with whitespace; got {:?}",
1057 empty.title,
1058 );
1059 }
1060
1061 #[test]
1066 fn create_tool_call_titles_delete_and_move_carry_paths() {
1067 let delete = create_tool_call("tu_d1", "Delete", &json!({"file_path": "/x/f.rs"}), None);
1068 assert_eq!(delete.title, "Delete /x/f.rs");
1069 assert_eq!(create_tool_call("tu_d2", "Delete", &json!({}), None).title, "Delete");
1070
1071 let move_both = create_tool_call(
1072 "tu_m1",
1073 "Move",
1074 &json!({"from_path": "/a/old.rs", "to_path": "/b/new.rs"}),
1075 None,
1076 );
1077 assert_eq!(move_both.title, "Move /a/old.rs -> /b/new.rs");
1078 assert_eq!(create_tool_call("tu_m2", "Move", &json!({}), None).title, "Move",);
1079 }
1080
1081 #[test]
1086 fn create_tool_call_titles_lsp_and_push_notification_carry_payload() {
1087 let lsp = create_tool_call(
1088 "tu_l1",
1089 "LSP",
1090 &json!({
1091 "operation": "documentSymbol",
1092 "filePath": "crates/forge-primitives/src/messages.rs",
1093 "line": 1,
1094 "character": 1,
1095 }),
1096 None,
1097 );
1098 assert_eq!(lsp.title, "LSP documentSymbol crates/forge-primitives/src/messages.rs");
1099
1100 let push = create_tool_call(
1101 "tu_p1",
1102 "PushNotification",
1103 &json!({"message": "reference capture: forge wire shape probe", "status": "proactive"}),
1104 None,
1105 );
1106 assert_eq!(push.title, "PushNotification reference capture: forge wire shape probe");
1107
1108 assert_eq!(create_tool_call("tu_l2", "LSP", &json!({}), None).title, "LSP");
1109 assert_eq!(
1110 create_tool_call("tu_p2", "PushNotification", &json!({}), None).title,
1111 "PushNotification",
1112 );
1113 }
1114
1115 #[test]
1123 fn create_tool_call_titles_client_tool_search_carries_query() {
1124 let with_query = create_tool_call(
1125 "tu_client_ts",
1126 "ToolSearch",
1127 &json!({"query": "schedule cron recurring"}),
1128 None,
1129 );
1130 assert_eq!(with_query.title, "ToolSearch schedule cron recurring");
1131
1132 let empty = create_tool_call("tu_client_ts2", "ToolSearch", &json!({}), None);
1134 assert_eq!(empty.title, "ToolSearch");
1135 assert!(
1136 !empty.title.ends_with(' '),
1137 "empty-query title must not end with whitespace; got {:?}",
1138 empty.title,
1139 );
1140 }
1141
1142 #[test]
1150 fn build_fields_client_tool_search_renders_compact_matched_tool_names() {
1151 let base = make_base("ToolSearch", &json!({"query": "select:Monitor,TaskStop"}));
1152 let raw_content = json!([
1153 {"type": "tool_reference", "tool_name": "Monitor"},
1154 {"type": "tool_reference", "tool_name": "TaskStop"},
1155 ]);
1156 let f = build_tool_result_fields(false, Some(&raw_content), Some(&base), None);
1157 let raw = f.raw_output.as_deref().expect("ToolSearch result must produce a raw_output");
1158 assert!(raw.contains("Monitor"), "raw_output must list Monitor; got {raw:?}");
1159 assert!(raw.contains("TaskStop"), "raw_output must list TaskStop; got {raw:?}");
1160 let Some(ToolCallContent::Content { content: ChunkContent::Text { text } }) =
1161 f.content.as_ref().and_then(|c| c.first())
1162 else {
1163 panic!("expected text content in ToolSearch card body, got: {:?}", f.content);
1164 };
1165 assert!(text.contains("Monitor") && text.contains("TaskStop"));
1166 }
1167
1168 #[test]
1172 fn build_fields_client_tool_search_handles_empty_matches() {
1173 let base = make_base("ToolSearch", &json!({"query": "select:Nothing"}));
1174 let raw_content = json!([]);
1175 let f = build_tool_result_fields(false, Some(&raw_content), Some(&base), None);
1176 assert_eq!(f.status, Some(forge_primitives::ToolCallStatus::Completed));
1177 }
1178
1179 #[test]
1180 fn create_tool_call_locations_from_file_path() {
1181 let r = create_tool_call("tu", "Edit", &json!({"file_path": "/foo"}), None);
1182 assert_eq!(r.locations.len(), 1);
1183 assert_eq!(r.locations[0].path, std::path::PathBuf::from("/foo"));
1184 }
1185
1186 #[test]
1187 fn edit_initial_content_is_diff() {
1188 let r = create_tool_call(
1189 "tu",
1190 "Edit",
1191 &json!({"file_path": "/f", "old_string": "a", "new_string": "b"}),
1192 None,
1193 );
1194 assert_eq!(r.content.len(), 1);
1195 let ToolCallContent::Diff { old, new, .. } = &r.content[0] else { panic!("expected diff") };
1196 assert_eq!(old, "a");
1197 assert_eq!(new, "b");
1198 }
1199
1200 #[test]
1201 fn write_initial_content_is_full_diff() {
1202 let r =
1203 create_tool_call("tu", "Write", &json!({"file_path": "/f", "content": "hello"}), None);
1204 assert_eq!(r.content.len(), 1);
1205 let ToolCallContent::Diff { old, new, .. } = &r.content[0] else { panic!() };
1206 assert_eq!(old, "");
1207 assert_eq!(new, "hello");
1208 }
1209
1210 #[test]
1211 fn extract_text_handles_string_array_object() {
1212 assert_eq!(extract_text(&json!("hi")), "hi");
1213 assert_eq!(
1214 extract_text(&json!([{"type":"text","text":"a"},{"type":"text","text":"b"}])),
1215 "a\nb"
1216 );
1217 assert_eq!(extract_text(&json!({"text":"o"})), "o");
1218 assert_eq!(extract_text(&json!(123)), "");
1219 }
1220
1221 #[test]
1228 fn extract_text_widens_to_prose_carrying_object_fields() {
1229 assert_eq!(
1230 extract_text(&json!({"plan": "Step 1\nStep 2", "allowedTools": ["Bash"]})),
1231 "Step 1\nStep 2",
1232 );
1233 assert_eq!(extract_text(&json!({"description": "desc text"})), "desc text");
1234 assert_eq!(extract_text(&json!({"summary": "summary text"})), "summary text");
1235 assert_eq!(extract_text(&json!({"message": "message text"})), "message text");
1236 }
1237
1238 #[test]
1242 fn extract_text_returns_empty_for_unrecognized_object() {
1243 let v = json!({"allowedTools": ["Bash"], "permissions": {"defaultBehavior": "ask"}});
1244 assert_eq!(extract_text(&v), "");
1245 }
1246
1247 #[test]
1250 fn build_fields_extracts_plan_text_for_exit_plan_mode() {
1251 let base = make_base("ExitPlanMode", &json!({"plan": "..."}));
1252 let raw_content = json!({
1253 "plan": "Step 1: do X\nStep 2: do Y",
1254 "allowedTools": ["Bash", "Read"],
1255 });
1256 let f = build_tool_result_fields(false, Some(&raw_content), Some(&base), None);
1257 assert_eq!(f.raw_output.as_deref(), Some("Step 1: do X\nStep 2: do Y"));
1258 let Some(ToolCallContent::Content { content: ChunkContent::Text { text } }) =
1259 f.content.as_ref().and_then(|c| c.first())
1260 else {
1261 panic!("expected text content");
1262 };
1263 assert_eq!(text, "Step 1: do X\nStep 2: do Y");
1264 }
1265
1266 #[test]
1271 fn build_fields_does_not_json_dump_unrecognized_object() {
1272 let base = make_base("ExitPlanMode", &json!({"plan": "..."}));
1273 let raw_content = json!({
1274 "allowedTools": ["Bash"],
1275 "permissions": {"defaultBehavior": "ask"},
1276 });
1277 let f = build_tool_result_fields(false, Some(&raw_content), Some(&base), None);
1278 assert!(
1279 f.raw_output.is_none(),
1280 "unrecognized JSON shape must not dump as raw_output, got: {:?}",
1281 f.raw_output
1282 );
1283 assert!(
1284 f.content.is_none(),
1285 "unrecognized JSON shape must not become content text, got: {:?}",
1286 f.content
1287 );
1288 }
1289
1290 #[test]
1291 fn normalize_text_skips_persisted_preview_line() {
1292 let v = json!(
1293 "<persisted-output>\npreview (first 2kb):\n│ hello world\n│ second line\n</persisted-output>"
1294 );
1295 let out = normalize_tool_result_text(&v, false);
1296 assert_eq!(out, "hello world");
1297 }
1298
1299 #[test]
1300 fn sanitize_user_rejected_text_collapses() {
1301 let v = json!(USER_REJECTED_TOOL_USE_EXACT);
1302 assert_eq!(normalize_tool_result_text(&v, true), "Cancelled by user.");
1303
1304 let custom = format!("{USER_REJECTED_TOOL_USE_PREFIX} extra reason");
1305 let v2 = json!(custom);
1306 assert_eq!(normalize_tool_result_text(&v2, true), "Cancelled by user.");
1307
1308 let pd = json!(PERMISSION_DENIED_TOOL_USE_EXACT);
1309 assert_eq!(normalize_tool_result_text(&pd, true), "Permission denied.");
1310 }
1311
1312 #[test]
1313 fn build_fields_bash_extracts_stdout_stderr() {
1314 let base = make_base("Bash", &json!({"command":"echo"}));
1315 let raw_content =
1316 json!([{"type":"text","text":"<persisted-output>\n│ hello\n</persisted-output>"}]);
1317 let raw_result = json!({"stdout":"hello\n","stderr":"","interrupted":false});
1318 let f = build_tool_result_fields(false, Some(&raw_content), Some(&base), Some(&raw_result));
1319 assert_eq!(f.status, Some(forge_primitives::ToolCallStatus::Completed));
1320 assert_eq!(f.raw_output.as_deref(), Some("hello\n"));
1321 let Some(ToolCallContent::Content { content: ChunkContent::Text { text } }) =
1323 f.content.as_ref().and_then(|c| c.first())
1324 else {
1325 panic!("expected text content");
1326 };
1327 assert!(text.contains("hello"));
1328 }
1329
1330 #[test]
1331 fn build_fields_read_file_unchanged_shortcut() {
1332 let base = make_base("Read", &json!({"file_path": "/x"}));
1333 let raw_result = json!([{"type":"file_unchanged","file":{"filePath":"/x"}}]);
1334 let f = build_tool_result_fields(
1335 false,
1336 Some(&json!("ignored")),
1337 Some(&base),
1338 Some(&raw_result),
1339 );
1340 assert_eq!(f.raw_output.as_deref(), Some("File unchanged: /x"));
1341 }
1342
1343 #[test]
1344 fn build_fields_write_emits_structured_diff() {
1345 let base = make_base("Write", &json!({"file_path":"/x","content":"new"}));
1346 let raw_content = json!([{"filePath":"/x","content":"new","originalFile":"old"}]);
1347 let f = build_tool_result_fields(false, Some(&raw_content), Some(&base), None);
1348 assert!(matches!(
1349 f.content.as_ref().and_then(|c| c.first()),
1350 Some(ToolCallContent::Diff { .. })
1351 ));
1352 }
1353
1354 #[test]
1355 fn build_fields_agent_title_from_agent_type() {
1356 let base = make_base("Agent", &json!({}));
1357 let raw_result = json!([{"agentType":"researcher"}]);
1358 let f = build_tool_result_fields(false, Some(&json!("ok")), Some(&base), Some(&raw_result));
1359 assert_eq!(f.title.as_deref(), Some("researcher"));
1360 }
1361
1362 #[test]
1363 fn build_fields_mcp_resource_structured() {
1364 let base = make_base("ReadMcpResource", &json!({"uri":"file:///x"}));
1365 let raw_content =
1366 json!({"contents":[{"uri":"file:///x","text":"hi","mimeType":"text/plain"}]});
1367 let f = build_tool_result_fields(false, Some(&raw_content), Some(&base), None);
1368 assert!(matches!(
1369 f.content.as_ref().and_then(|c| c.first()),
1370 Some(ToolCallContent::McpResource { .. })
1371 ));
1372 }
1373
1374 #[test]
1375 fn build_fields_bash_output_metadata() {
1376 let base = make_base("Bash", &json!({"command":"sleep 30 &"}));
1377 let raw_result = json!({"backgroundTaskId":"bg1","assistantAutoBackgrounded":true});
1378 let f = build_tool_result_fields(
1379 false,
1380 Some(&json!("started")),
1381 Some(&base),
1382 Some(&raw_result),
1383 );
1384 let meta = f.output_metadata.expect("meta");
1385 assert_eq!(meta.bash.unwrap().assistant_auto_backgrounded, Some(true));
1386 }
1387
1388 #[test]
1389 fn build_fields_agent_background_does_not_stamp_completed() {
1390 let base = make_base("Agent", &json!({"subagent_type": "Explore"}));
1396 let raw_result = json!({"backgroundTaskId": "bg-agent-1"});
1397 let f = build_tool_result_fields(
1398 false,
1399 Some(&json!("Running in background with ID: bg-agent-1")),
1400 Some(&base),
1401 Some(&raw_result),
1402 );
1403 assert!(
1404 f.status.is_none(),
1405 "backgrounded Agent tool_result must not stamp a terminal status; got {:?}",
1406 f.status,
1407 );
1408 }
1409
1410 #[test]
1411 fn unwrap_tool_use_result_envelopes() {
1412 let v = json!({"is_error": true, "content": "boom"});
1413 let u = unwrap_tool_use_result(&v);
1414 assert!(u.is_error);
1415 assert_eq!(u.content, json!("boom"));
1416
1417 let v2 = json!({"result": "ok"});
1418 let u2 = unwrap_tool_use_result(&v2);
1419 assert!(!u2.is_error);
1420 assert_eq!(u2.content, json!("ok"));
1421
1422 let v3 = json!("plain");
1423 let u3 = unwrap_tool_use_result(&v3);
1424 assert_eq!(u3.content, json!("plain"));
1425 }
1426}