Skip to main content

forge_agent/
tooling.rs

1//! Tool-call construction + tool-result extraction. 1:1 port of
2//! upstream's `agent-sdk/src/bridge/tooling.ts` (714 `LoC`).
3//!
4//! Three groups of helpers:
5//! 1. Front-of-tool: `create_tool_call`, `tool_title`, `normalize_tool_kind`.
6//! 2. Result extraction: `build_tool_result_fields` per-tool branches
7//!    (Bash / Read / Write / Edit / Agent / `ReadMcpResource`),
8//!    `normalize_tool_result_text`, `extract_text`,
9//!    `persisted_output_first_line`, `sanitize_sdk_rejection_text`,
10//!    `extract_tool_output_metadata`.
11//! 3. Helpers: `unwrap_tool_use_result`.
12
13use serde_json::{Map, Value, json};
14
15use forge_primitives::{
16    BashOutputMetadata, ChunkContent, ToolCall, ToolCallContent, ToolCallLocation,
17    ToolCallUpdateFields, ToolOutputMetadata,
18};
19
20// Tool-result preview-size cap. Only the preview limit surfaces in
21// user-visible text, so a flat const suffices.
22const 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        // Render as e.g. 1.5KB without going through f64. Only one
31        // decimal place - match the previous Display behaviour.
32        let tenths = (remainder * 10) / 1024;
33        format!("{whole_kb}.{tenths}KB")
34    }
35}
36
37/// Block types the CLI uses for tool results. Mirrors upstream's
38/// `TOOL_RESULT_TYPES` set in tooling.ts:5.
39const 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
54/// Mirrors `normalizeToolKind`. Maps tool name to the typed
55/// `ToolKind` consumed by a view's tool-card renderer.
56fn 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        // "Task" / "Agent" fall through to the "think" default.
68        _ => ToolKind::Think,
69    }
70}
71
72/// Mirrors `toolTitle(name, input)`. Produces the human-friendly
73/// title shown on the tool card header (e.g. Bash → command, Glob →
74/// pattern + path, etc.).
75fn 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 surfaces with two wire shapes that both render
141        // as the same card: the server `ServerToolUse` discriminator
142        // (`tool_search_tool_regex` / `_bm25`) AND the CLIENT tool_use
143        // a forge agent calls via deferred tools (wire name literal
144        // `"ToolSearch"`, input `{query, max_results}`). Pair them in
145        // one arm so the title carries the query regardless of which
146        // side of the wire the call comes from. Mirror of
147        // `theme::tool_name_label`'s `"ToolSearch" |
148        // "tool_search_tool_regex" | "tool_search_tool_bm25"` pairing.
149        "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        // Wire shape: `{skill: "<name>", args?: "<free text>"}`. The
158        // args ride the title like Bash's command; the render clips.
159        "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
184/// Mirrors `editDiffContent(name, input)` - initial diff content for
185/// Edit / Write tool cards (before the result lands). Empty Vec for
186/// other tools.
187fn 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
225/// Mirrors `createToolCall(toolUseId, name, input, parentToolUseId)`.
226/// Builds the rich `ToolCall` envelope upstream emits when the
227/// assistant first invokes a tool.
228pub 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
258// ----- text extraction + persisted-output preview -----
259
260/// Mirrors `extractText(value)` - flattens a `tool_result` content
261/// payload into a single `String`. Accepts string, array of
262/// `{ type: "text", text }` blocks, or a single object carrying prose
263/// under one of `plan` / `description` / `summary` / `message` /
264/// `text` (priority order: plan-mode-specific first, generic last).
265///
266/// Returns `""` for any other shape so the caller knows the value
267/// can't be rendered as user-facing text - the alternative
268/// (JSON-stringifying arbitrary structures) leaks raw `\n` escapes
269/// into chat (bug #136).
270fn 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
332// ----- SDK rejection sanitization -----
333
334const 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
363// ----- Result-record candidate walking -----
364
365fn 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
418// ----- per-tool extraction -----
419
420fn 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
449/// True when a `tool_result` carries a background sentinel - the same
450/// fields Bash keys on (`backgroundTaskId` / `assistantAutoBackgrounded`
451/// / `backgroundedByUser`).
452fn 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
747// ----- output_metadata extractor -----
748
749fn 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
780// ----- main entry: build_tool_result_fields -----
781
782/// Mirrors `buildToolResultFields(isError, rawContent, base?, rawResult?)`.
783/// The high-level entry that turns an arbitrary `tool_result` block into
784/// the structured `ToolCallUpdateFields` upstream's TUI consumes.
785pub 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    // Read: file_unchanged shortcut. Early return.
802    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    // Agent: title from agentType, and never stamp Completed on a
816    // background sentinel while the subagent runs (mirrors Bash).
817    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    // Bash: extract structured output, otherwise normalise the text.
828    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        // Defensive: when `extract_text` couldn't recognize the shape,
838        // only fall back for primitive string values. JSON-dumping
839        // arbitrary objects/arrays leaks raw `\n` escapes into chat
840        // (bug #136). `extract_text` already handles `Value::String`,
841        // so this branch is effectively unreachable for well-formed
842        // wire content - kept as a safe path.
843        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    // output_metadata: per-tool.
850    if let Some(meta) = extract_tool_output_metadata(&tool_name, raw_result, raw_content) {
851        fields.output_metadata = Some(meta);
852    }
853
854    // Write: structured diff.
855    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    // Edit: structured diff (fall through to base.content if already set).
869    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    // ReadMcpResource: structured resource content.
882    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    // ToolSearch: the deferred-tools client tool's result wire shape
891    // is an array of `{type: tool_reference, tool_name: "..."}` blocks
892    // that `extract_text` filters to "". Walk the array for the
893    // tool_name fields directly and synthesise a compact one-line
894    // summary so the card body shows what the search matched without
895    // dumping the raw `<functions>` schema.
896    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    // Generic fallback: wrap raw_output as content.
909    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
917/// Walk a `tool_result.content` array looking for the CLIENT
918/// `ToolSearch` result wire shape -
919/// `[{type: "tool_reference", tool_name: "..."}, ...]` - and return
920/// the matched tool names in source order. Anything not a
921/// tool_reference entry is skipped, so a mixed-content result still
922/// surfaces the references it carried.
923fn 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
942/// Mirrors `unwrapToolUseResult(rawResult)`. Walks the `tool_result`
943/// envelope: if the value is an object, surfaces the inner `content`
944/// / `result` / `text` field and the `is_error` / `error` flag.
945pub 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    /// Server-side tools (`ServerToolUse` wire blocks) - the wire `name`
998    /// is the discriminator literal (e.g. `tool_search_tool_regex`,
999    /// `web_search`). Without a tool_title arm the title falls back to
1000    /// the raw wire name and the card body is empty - the LLM's query
1001    /// is invisible. Pull the relevant input field (query / url) so the
1002    /// card carries it.
1003    #[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    /// The CLI's Skill tool_use input is a `skill` name plus optional
1035    /// free-text `args` (live shape, reference-captures/skill.jsonl and
1036    /// Ved's own transcripts). Without an arm the title fell back to
1037    /// the bare "Skill", so the L1 card header and the L2 group child
1038    /// row carried no argument information.
1039    #[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    /// Delete and Move arms per issue #848. UNVERIFIED wire shapes:
1062    /// zero live traffic, no reference capture, and no tool definition
1063    /// in the CLI bundle - the field names follow the issue's spec, and
1064    /// a mismatch renders the bare name exactly as the missing arm did.
1065    #[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    /// LSP and PushNotification shapes from the reference captures
1082    /// (lsp.jsonl: operation + filePath + line/character;
1083    /// push_notification.jsonl: message + status). The title carries
1084    /// the discriminative fields and drops the positional ones.
1085    #[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    /// CLIENT-side `ToolSearch` (deferred-tools tool a forge agent
1116    /// calls itself; wire name is the literal `"ToolSearch"`, not
1117    /// the server `tool_search_tool_*` discriminator). Locks the
1118    /// client-name -> query-arm pairing - without it the card
1119    /// renders `\u{2316} ToolSearch ToolSearch` (duplicated label,
1120    /// since the generic fallback returns the bare name) and the
1121    /// user can't see the query the LLM searched for.
1122    #[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        // Empty-query: stable title, no trailing space.
1133        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    /// Client ToolSearch's tool_result wire shape is an array of
1143    /// `{type: tool_reference, tool_name: "..."}` blocks - the
1144    /// generic `extract_text` walker filters those out (no `text`
1145    /// field) and the card body renders empty. A ToolSearch-aware
1146    /// branch in `build_tool_result_fields` should surface the
1147    /// matched tool names so the user sees what the search found
1148    /// without expanding to a raw schema dump.
1149    #[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    /// Defensive: a ToolSearch result with no tool_reference entries
1169    /// (empty matches) must not blow up. Fields stay set so the card
1170    /// reads as completed even with no body.
1171    #[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    /// Bug #136: ExitPlanMode result is a JSON object whose plan body
1222    /// lives under "plan", with auxiliary fields like "allowedTools"
1223    /// alongside. `extract_text` historically returned "" for this
1224    /// shape, forcing the line-763 fallback to JSON-stringify the
1225    /// whole object (with literal `\n` escapes leaking into chat).
1226    /// Widen recognition to the conventional prose-field names.
1227    #[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    /// Unrecognized JSON object (no prose-carrying field) returns "" -
1239    /// the caller path then refuses to dump the raw JSON (see
1240    /// `build_fields_does_not_json_dump_unrecognized_object`).
1241    #[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    /// Bug #136 regression test: ExitPlanMode-shaped tool_use_result
1248    /// must render the plan body cleanly, not as raw-JSON-with-escapes.
1249    #[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    /// For any other JSON-object-shaped result that carries no
1267    /// prose-bearing field, the fallback must NOT JSON-stringify the
1268    /// raw value into chat. Better to render an empty body than a
1269    /// literal-escape blob.
1270    #[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        // generic content fallback wraps the bash output as text.
1322        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        // A subagent the CLI backgrounds returns an immediate sentinel
1391        // tool_result carrying `backgroundTaskId` while it keeps
1392        // running. The card must not be stamped Completed mid-run;
1393        // liveness is tracked separately by the renderer's background-task
1394        // roster.
1395        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}