Skip to main content

mermaid_cli/domain/
transition.rs

1//! Helpers that enforce invariants during turn-state transitions.
2//!
3//! The reducer calls these so the type system — not a comment or a
4//! convention — guarantees that you can't transition to the
5//! follow-up model call with missing tool outcomes, or commit a
6//! partial assistant message that's still streaming, or drop a
7//! thinking signature that the next request needs.
8//!
9//! Everything here is pure and sync.
10
11use std::time::SystemTime;
12
13use crate::models::tool_call::ToolCall as ModelToolCall;
14use crate::models::{ChatMessage, MessageRole, ProviderContinuation};
15
16use super::action::{ActionDetails, ActionDisplay, ActionResult};
17use super::ids::{ToolCallId, TurnId};
18use super::runtime::ToolMetadata;
19use super::state::{GenPhase, PendingToolCall, ToolOutcome, TurnState};
20
21/// Flatten `Vec<Option<ToolOutcome>>` into `Vec<ToolOutcome>` iff
22/// every slot is populated. `None` means "still waiting on at least
23/// one tool" — the reducer stays in `ExecutingTools` and drops the
24/// event without state change.
25///
26/// This is the single gate between `ExecutingTools` and the follow-up
27/// `Generating`. It's impossible to bypass: there is no public
28/// constructor for `Vec<ToolOutcome>` elsewhere in the codebase, and
29/// the follow-up transition's builder function takes `Vec<ToolOutcome>`
30/// by value.
31pub fn try_complete_outcomes(outcomes: &[Option<ToolOutcome>]) -> Option<Vec<ToolOutcome>> {
32    // `Option<Vec<T>>: FromIterator<Option<T>>` short-circuits to `None` on the
33    // first empty slot — same semantics as the explicit loop, one line.
34    outcomes.iter().cloned().collect()
35}
36
37/// Write the outcome for a specific tool call ID into the slot
38/// carrying that call. Returns `true` if the slot was found and empty;
39/// `false` if the call isn't pending (stale event) or was already
40/// filled (duplicate event — first write wins).
41pub fn fill_outcome(
42    calls: &[PendingToolCall],
43    outcomes: &mut [Option<ToolOutcome>],
44    call_id: ToolCallId,
45    outcome: ToolOutcome,
46) -> bool {
47    debug_assert_eq!(
48        calls.len(),
49        outcomes.len(),
50        "calls and outcomes must be aligned"
51    );
52    let Some(idx) = calls.iter().position(|c| c.call_id == call_id) else {
53        return false;
54    };
55    if outcomes[idx].is_some() {
56        return false;
57    }
58    outcomes[idx] = Some(outcome);
59    true
60}
61
62/// Transition `Idle → Generating`. Always pure: the caller builds a
63/// `ChatRequest` separately and returns it to the reducer as a `Cmd`.
64/// `now` is the reducer step's injected clock (`state.now`), so the
65/// `started` stamp is deterministic on replay rather than read live (Cause 3).
66pub fn start_generating(id: TurnId, now: SystemTime) -> TurnState {
67    start_generating_with(id, now, false)
68}
69
70/// `start_generating` with an explicit continuation flag. Used by the
71/// auto-continue tail (and the paths that must carry its flag forward:
72/// empty-retry, truncation-recovery resume) so the eventual commit stamps
73/// `ChatMessageKind::Continuation`.
74pub fn start_generating_with(id: TurnId, now: SystemTime, continuation: bool) -> TurnState {
75    TurnState::Generating {
76        id,
77        started: now,
78        partial_text: String::new(),
79        partial_reasoning: String::new(),
80        tokens: 0,
81        phase: GenPhase::Sending,
82        provider_continuation: None,
83        pending_tool_calls: Vec::new(),
84        continuation,
85    }
86}
87
88/// Transition `Generating → ExecutingTools`. Allocates `None` slots
89/// for every call so the invariant ("outcomes.len() == calls.len()")
90/// is upheld by construction. `now` is the reducer step's injected clock
91/// (`state.now`) so `started` is deterministic on replay (Cause 3).
92pub fn start_executing_tools(
93    id: TurnId,
94    calls: Vec<PendingToolCall>,
95    now: SystemTime,
96) -> TurnState {
97    let outcomes = vec![None; calls.len()];
98    TurnState::ExecutingTools {
99        id,
100        started: now,
101        calls,
102        outcomes,
103    }
104}
105
106/// Build the committed assistant message from a `Generating` state's
107/// accumulated content. Safe to call with empty text (the model might
108/// have responded with only tool calls). Returns the message plus the
109/// provider continuation state needed for the next model call.
110pub fn commit_assistant_message(
111    partial_text: String,
112    partial_reasoning: String,
113    tool_calls: Vec<ModelToolCall>,
114    provider_continuation: Option<ProviderContinuation>,
115    now: chrono::DateTime<chrono::Local>,
116    continuation: bool,
117) -> ChatMessage {
118    let thinking = if partial_reasoning.is_empty() {
119        None
120    } else {
121        Some(partial_reasoning)
122    };
123    let kind = if continuation {
124        crate::models::ChatMessageKind::Continuation
125    } else {
126        crate::models::ChatMessageKind::Normal
127    };
128    ChatMessage {
129        role: MessageRole::Assistant,
130        content: partial_text,
131        timestamp: now,
132        kind,
133        metadata: None,
134        actions: Vec::new(),
135        thinking,
136        images: None,
137        image_numbers: None,
138        tool_calls: if tool_calls.is_empty() {
139            None
140        } else {
141            Some(tool_calls)
142        },
143        tool_call_id: None,
144        tool_name: None,
145        provider_continuation,
146    }
147}
148
149/// Build the follow-up `tool` role messages from completed outcomes.
150/// The OpenAI-compatible wire format requires (tool_call_id, tool_name,
151/// content) — we pull name from the original call.
152pub fn tool_result_messages(
153    calls: &[PendingToolCall],
154    outcomes: Vec<ToolOutcome>,
155) -> Vec<ChatMessage> {
156    debug_assert_eq!(calls.len(), outcomes.len());
157    calls
158        .iter()
159        .zip(outcomes)
160        .map(|(call, outcome)| {
161            let tool_call_id = call
162                .source
163                .id
164                .clone()
165                .unwrap_or_else(|| format!("call_{}", call.call_id.0));
166            ChatMessage::tool(
167                tool_call_id,
168                call.source.function.name.clone(),
169                outcome.as_tool_message_content(),
170            )
171        })
172        .collect()
173}
174
175/// Convert a completed tool outcome into an `ActionDisplay` entry
176/// attached to the assistant message that triggered the call. Used so
177/// the chat renderer can show "Read main.rs → 1,234 bytes" etc.
178pub fn action_display_for(call: &PendingToolCall, outcome: &ToolOutcome) -> ActionDisplay {
179    let (action_type, target) = display_info_for(call);
180    // `write_file` that overwrote an existing file is an *update*, not a fresh
181    // write — relabel it so the transcript reads "Update" (as Claude Code does),
182    // reserving "Write" for a genuinely new file. The tool records which case it
183    // was in its outcome metadata; the pending call couldn't know yet.
184    let (action_type, target) = match &outcome.metadata.detail {
185        ToolMetadata::WriteFile {
186            created: Some(false),
187            ..
188        } => ("Update".to_string(), target),
189        // `apply_patch` is the model's tool name; the transcript should read the
190        // action verb + the file(s) it touched, derived from the outcome's
191        // per-file A/M/D/R lists (unknown at call time, filled in post-exec).
192        ToolMetadata::ApplyPatch {
193            added,
194            modified,
195            deleted,
196            renamed,
197            ..
198        } => apply_patch_action(added, modified, deleted, renamed).unwrap_or((action_type, target)),
199        _ => (action_type, target),
200    };
201    let duration = outcome.duration_secs;
202    let result = if outcome.is_success() {
203        ActionResult::Success {
204            output: outcome.output().to_string(),
205            images: outcome.images(),
206        }
207    } else {
208        ActionResult::Error {
209            error: outcome.error_message().unwrap_or("[cancelled]").to_string(),
210        }
211    };
212    let details = action_details_for(call, outcome, duration);
213    ActionDisplay {
214        action_type,
215        target,
216        result,
217        details,
218        duration_seconds: duration,
219        metadata: Some((*outcome.metadata).clone()),
220    }
221}
222
223fn action_details_for(
224    call: &PendingToolCall,
225    outcome: &ToolOutcome,
226    duration: Option<f64>,
227) -> ActionDetails {
228    if !outcome.is_success() {
229        return ActionDetails::Simple;
230    }
231
232    let name = call.source.function.name.as_str();
233    let args = &call.source.function.arguments;
234    match name {
235        "read_file" => {
236            let line_count = outcome
237                .metadata
238                .line_count
239                .or_else(|| metadata_line_count(&outcome.metadata.detail))
240                .unwrap_or_else(|| outcome.output().lines().count());
241            ActionDetails::Preview {
242                text: success_summary(
243                    format!("{} {} read", line_count, pluralize("line", line_count)),
244                    duration,
245                ),
246                line_count: Some(line_count),
247            }
248        },
249        "write_file" => {
250            let line_count = outcome
251                .metadata
252                .line_count
253                .or_else(|| metadata_line_count(&outcome.metadata.detail))
254                .or_else(|| {
255                    args.get("content")
256                        .and_then(|v| v.as_str())
257                        .map(|content| content.lines().count())
258                })
259                .unwrap_or(0);
260            if let Some(diff) = outcome.metadata.display_diff.clone() {
261                ActionDetails::Diff {
262                    summary: diff_success_summary(&diff, duration),
263                    diff,
264                }
265            } else {
266                let content = args
267                    .get("content")
268                    .and_then(|v| v.as_str())
269                    .unwrap_or_default()
270                    .to_string();
271                ActionDetails::FileContent {
272                    line_count,
273                    content,
274                }
275            }
276        },
277        "web_search" => {
278            let result_count = outcome
279                .metadata
280                .result_count
281                .or_else(|| metadata_result_count(&outcome.metadata.detail))
282                .unwrap_or_else(|| count_search_results(outcome.output()));
283            let mut detail = format!(
284                "{} {} returned",
285                result_count,
286                pluralize("result", result_count)
287            );
288            if let ToolMetadata::WebSearch {
289                backend,
290                failed_queries,
291                truncated,
292                ..
293            } = &outcome.metadata.detail
294            {
295                if !backend.is_empty() {
296                    detail.push_str(&format!(" via {backend}"));
297                }
298                if *failed_queries > 0 {
299                    detail.push_str(&format!(" · {failed_queries} failed"));
300                }
301                if *truncated {
302                    detail.push_str(" · truncated");
303                }
304            }
305            ActionDetails::Preview {
306                text: success_summary(detail, duration),
307                line_count: None,
308            }
309        },
310        "web_fetch" => {
311            let line_count = outcome
312                .metadata
313                .line_count
314                .or_else(|| metadata_line_count(&outcome.metadata.detail))
315                .unwrap_or_else(|| outcome.output().lines().count());
316            let mut detail = format!("{} {} fetched", line_count, pluralize("line", line_count));
317            if let ToolMetadata::WebFetch {
318                url,
319                final_url,
320                backend,
321                status,
322                media_type,
323                extraction,
324                output_byte_count,
325                truncated,
326                pattern,
327                match_count,
328                snapshot_id,
329                ..
330            } = &outcome.metadata.detail
331            {
332                if !backend.is_empty() {
333                    detail.push_str(&format!(" via {backend}"));
334                }
335                if let Some(status) = status {
336                    detail.push_str(&format!(" · HTTP {status}"));
337                }
338                if let Some(media_type) = media_type {
339                    detail.push_str(&format!(" · {media_type}"));
340                }
341                if !extraction.is_empty() {
342                    detail.push_str(&format!(" · {extraction}"));
343                }
344                if *output_byte_count > 0 {
345                    detail.push_str(&format!(" · {output_byte_count} extracted bytes"));
346                }
347                if let Some(pattern) = pattern {
348                    let match_count = match_count.unwrap_or(0);
349                    let pattern = crate::utils::redact_secrets(pattern);
350                    detail.push_str(&format!(
351                        " · {match_count} {} for {:?}",
352                        pluralize("match", match_count),
353                        crate::utils::truncate_middle(&pattern, 48)
354                    ));
355                }
356                if let Some(final_url) = final_url.as_deref().filter(|final_url| *final_url != url)
357                {
358                    detail.push_str(&format!(
359                        " · final {}",
360                        crate::utils::truncate_middle(final_url, 96)
361                    ));
362                }
363                if *truncated {
364                    detail.push_str(" · truncated");
365                }
366                if let Some(snapshot_id) = snapshot_id {
367                    detail.push_str(&format!(" · {snapshot_id}"));
368                }
369            }
370            ActionDetails::Preview {
371                text: success_summary(detail, duration),
372                line_count: Some(line_count),
373            }
374        },
375        "execute_command" => ActionDetails::Preview {
376            text: command_success_summary(outcome, duration),
377            line_count: outcome
378                .metadata
379                .line_count
380                .or_else(|| metadata_line_count(&outcome.metadata.detail))
381                .or_else(|| Some(outcome.output().lines().count())),
382        },
383        "apply_patch" => {
384            if let Some(diff) = outcome.metadata.display_diff.clone() {
385                ActionDetails::Diff {
386                    summary: diff_success_summary(&diff, duration),
387                    diff,
388                }
389            } else {
390                // Fallback when a patch produced no display diff (effectively
391                // never — apply_patch always sets one). `outcome.summary` already
392                // carries its own timing, so use it directly (no re-wrap).
393                ActionDetails::Preview {
394                    text: outcome.summary.clone(),
395                    line_count: None,
396                }
397            }
398        },
399        "agent" => {
400            // Surface what the child actually cost and which model ran it —
401            // the child's usage is real spend the parent's own counters would
402            // otherwise hide.
403            let mut bits = Vec::new();
404            if let Some(usage) = outcome.metadata.token_usage.as_ref()
405                && usage.total_tokens() > 0
406            {
407                bits.push(format!(
408                    "{} tokens",
409                    super::compaction::format_compact_count(usage.total_tokens())
410                ));
411            }
412            if let ToolMetadata::Subagent { model_id, agent_id } = &outcome.metadata.detail {
413                if !model_id.is_empty() {
414                    bits.push(model_id.clone());
415                }
416                if !agent_id.is_empty() {
417                    bits.push(agent_id.clone());
418                }
419            }
420            let detail = if bits.is_empty() {
421                "subagent finished".to_string()
422            } else {
423                bits.join(" · ")
424            };
425            ActionDetails::Preview {
426                text: success_summary(detail, duration),
427                line_count: None,
428            }
429        },
430        "task_create" | "task_update" => {
431            // Progress after the call, plus the model's pivot rationale when
432            // it gave one — the user should see WHY the plan changed shape.
433            let mut text = if let ToolMetadata::Tasks {
434                completed, total, ..
435            } = &outcome.metadata.detail
436            {
437                format!("Tasks {completed}/{total}")
438            } else {
439                outcome.summary.clone()
440            };
441            if let Some(explanation) = args.get("explanation").and_then(|v| v.as_str())
442                && !explanation.trim().is_empty()
443            {
444                text.push_str(" · ");
445                text.push_str(explanation.trim());
446            }
447            ActionDetails::Preview {
448                text: success_summary(text, duration),
449                line_count: None,
450            }
451        },
452        // Fallback for non-answered resolutions (dismissed, chat-about-this,
453        // headless) and recordings from before answers rode the metadata —
454        // an answered call renders from `ToolMetadata::Questions` instead.
455        "ask_user_question" => ActionDetails::Preview {
456            text: success_summary(outcome.summary.clone(), duration),
457            line_count: None,
458        },
459        _ => ActionDetails::Simple,
460    }
461}
462
463/// Verb + target for an `apply_patch` outcome, derived from the files it
464/// actually touched. A single-file patch reads with the operation-specific verb
465/// (like `write_file`/`delete_file`); a multi-file patch folds to
466/// `Update(N files)` (the diff body lists each file). `None` on an empty patch
467/// so the caller keeps the original label.
468fn apply_patch_action(
469    added: &[String],
470    modified: &[String],
471    deleted: &[String],
472    renamed: &[(String, String)],
473) -> Option<(String, String)> {
474    match added.len() + modified.len() + deleted.len() + renamed.len() {
475        0 => None,
476        1 => modified
477            .first()
478            .map(|p| ("Update".to_string(), p.clone()))
479            .or_else(|| added.first().map(|p| ("Write".to_string(), p.clone())))
480            .or_else(|| deleted.first().map(|p| ("Delete".to_string(), p.clone())))
481            .or_else(|| {
482                renamed
483                    .first()
484                    .map(|(_, dst)| ("Update".to_string(), dst.clone()))
485            }),
486        n => Some(("Update".to_string(), format!("{n} files"))),
487    }
488}
489
490fn diff_success_summary(diff: &str, duration: Option<f64>) -> String {
491    let (added, removed) = diff_counts(diff);
492    let changes = format_line_changes(added, removed);
493    match duration {
494        Some(seconds) => format!("{}, took {}", changes, format_duration(seconds)),
495        None => changes,
496    }
497}
498
499/// Claude-Code-style line-change phrasing: "Added N lines", "Removed N lines",
500/// or "Added N lines, removed M lines" — a plain statement, no status prefix.
501fn format_line_changes(added: usize, removed: usize) -> String {
502    match (added, removed) {
503        (0, 0) => "No changes".to_string(),
504        (a, 0) => format!("Added {} {}", a, pluralize("line", a)),
505        (0, r) => format!("Removed {} {}", r, pluralize("line", r)),
506        (a, r) => format!(
507            "Added {} {}, removed {} {}",
508            a,
509            pluralize("line", a),
510            r,
511            pluralize("line", r),
512        ),
513    }
514}
515
516fn diff_counts(diff: &str) -> (usize, usize) {
517    let mut added = 0usize;
518    let mut removed = 0usize;
519    for line in diff.lines() {
520        match crate::render::diff::parse_diff_line(line) {
521            crate::render::diff::DiffLineKind::Added => added += 1,
522            crate::render::diff::DiffLineKind::Removed => removed += 1,
523            crate::render::diff::DiffLineKind::Context => {},
524        }
525    }
526    (added, removed)
527}
528
529fn metadata_line_count(metadata: &ToolMetadata) -> Option<usize> {
530    match metadata {
531        ToolMetadata::ReadFile { line_count, .. }
532        | ToolMetadata::WriteFile { line_count, .. }
533        | ToolMetadata::WebFetch { line_count, .. } => Some(*line_count),
534        ToolMetadata::ExecuteCommand {
535            stdout_lines,
536            stderr_lines,
537            ..
538        } => Some(stdout_lines + stderr_lines),
539        _ => None,
540    }
541}
542
543fn metadata_result_count(metadata: &ToolMetadata) -> Option<usize> {
544    match metadata {
545        ToolMetadata::WebSearch { result_count, .. } => Some(*result_count),
546        _ => None,
547    }
548}
549
550fn success_summary(detail: String, duration: Option<f64>) -> String {
551    match duration {
552        Some(seconds) => format!("{}, took {}", detail, format_duration(seconds)),
553        None => detail,
554    }
555}
556
557fn command_success_summary(outcome: &ToolOutcome, duration: Option<f64>) -> String {
558    if outcome.metadata.process.is_none() {
559        return success_summary("command completed".to_string(), duration);
560    }
561
562    let mut lines = vec![success_summary(
563        "background process started".to_string(),
564        duration,
565    )];
566    for line in outcome.output().lines().skip(1) {
567        if line.starts_with("--- startup output ---") {
568            break;
569        }
570        if !line.trim().is_empty() {
571            lines.push(line.to_string());
572        }
573    }
574    lines.join("\n")
575}
576
577fn format_duration(seconds: f64) -> String {
578    if seconds < 1.0 {
579        format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
580    } else if seconds < 10.0 {
581        format!("{:.1}s", seconds)
582    } else {
583        format!("{}s", seconds.round() as u64)
584    }
585}
586
587/// Human-readable duration for a finished run: "Ns", "Mm Ss", or "Hh Mm".
588pub fn format_run_duration(seconds: u64) -> String {
589    if seconds < 60 {
590        format!("{seconds}s")
591    } else if seconds < 3600 {
592        format!("{}m {}s", seconds / 60, seconds % 60)
593    } else {
594        format!("{}h {}m", seconds / 3600, (seconds % 3600) / 60)
595    }
596}
597
598fn pluralize(word: &str, count: usize) -> String {
599    if count == 1 {
600        word.to_string()
601    } else {
602        format!("{}s", word)
603    }
604}
605
606fn count_search_results(output: &str) -> usize {
607    output
608        .lines()
609        .filter(|line| line.starts_with('[') && line.contains("] Title:"))
610        .count()
611}
612
613/// Best-effort name + target extraction from a tool call, for chat
614/// display ("Read src/main.rs", "Bash cargo test", etc). Matches on
615/// the wire-format tool name + arguments; unknown tools fall through
616/// to the raw function name.
617pub fn display_info_for(call: &PendingToolCall) -> (String, String) {
618    let name = call.source.function.name.as_str();
619    let args = &call.source.function.arguments;
620    let string_arg =
621        |k: &str| -> Option<String> { args.get(k).and_then(|v| v.as_str()).map(str::to_string) };
622    match name {
623        "read_file" => {
624            let target = string_arg("path")
625                .or_else(|| {
626                    args.get("paths")
627                        .and_then(|v| v.as_array())
628                        .map(|a| match a.len() {
629                            0 => "(no paths)".to_string(),
630                            1 => a[0].as_str().unwrap_or("").to_string(),
631                            n => format!("{} files", n),
632                        })
633                })
634                .unwrap_or_default();
635            ("Read".to_string(), target)
636        },
637        "write_file" => ("Write".to_string(), string_arg("path").unwrap_or_default()),
638        // `apply_patch` can touch several files in one call, so the bare call has
639        // no single target here — the per-file A/M/D/R summary rides in the diff
640        // detail (`action_details_for`).
641        "apply_patch" => ("Apply patch".to_string(), String::new()),
642        "delete_file" => ("Delete".to_string(), string_arg("path").unwrap_or_default()),
643        "create_directory" => (
644            "Bash".to_string(),
645            format!("mkdir -p {}", string_arg("path").unwrap_or_default()),
646        ),
647        "execute_command" => (
648            "Bash".to_string(),
649            string_arg("command").unwrap_or_default(),
650        ),
651        "web_search" => {
652            let target = string_arg("query")
653                .or_else(|| {
654                    args.get("queries")
655                        .and_then(|v| v.as_array())
656                        .map(|a| match a.len() {
657                            0 => "(no queries)".to_string(),
658                            1 => a[0]
659                                .get("query")
660                                .and_then(|q| q.as_str())
661                                .unwrap_or("")
662                                .to_string(),
663                            n => format!("{} queries", n),
664                        })
665                })
666                .unwrap_or_default();
667            (
668                "Web Search".to_string(),
669                crate::utils::redact_secrets(&target),
670            )
671        },
672        "web_fetch" => {
673            let target = string_arg("url")
674                .map(|url| crate::utils::sanitize_url_for_display(&url))
675                .or_else(|| string_arg("snapshot_id"))
676                .unwrap_or_default();
677            ("Web Fetch".to_string(), target)
678        },
679        "memory" => {
680            let action = string_arg("action").unwrap_or_default();
681            let target = string_arg("id")
682                .or_else(|| string_arg("name"))
683                .unwrap_or_default();
684            let scope = if args
685                .get("global")
686                .and_then(|v| v.as_bool())
687                .unwrap_or(false)
688            {
689                " [global]"
690            } else if args
691                .get("shared")
692                .and_then(|v| v.as_bool())
693                .unwrap_or(false)
694            {
695                " [shared]"
696            } else {
697                ""
698            };
699            (
700                "Memory".to_string(),
701                format!("{action} {target}{scope}").trim().to_string(),
702            )
703        },
704        "agent" => (
705            "Agent".to_string(),
706            string_arg("description").unwrap_or_default(),
707        ),
708        "task_create" => {
709            let count = args
710                .get("tasks")
711                .and_then(|v| v.as_array())
712                .map_or(0, Vec::len);
713            (
714                "Tasks".to_string(),
715                format!("plan {count} {}", pluralize("step", count)),
716            )
717        },
718        "task_update" => {
719            let count = args
720                .get("updates")
721                .and_then(|v| v.as_array())
722                .map_or(0, Vec::len);
723            (
724                "Tasks".to_string(),
725                format!("update {count} {}", pluralize("step", count)),
726            )
727        },
728        "task_list" => ("Tasks".to_string(), "list".to_string()),
729        n if n.starts_with("mcp__") => {
730            let rest = &n[5..];
731            let target = rest.replacen("__", ":", 1);
732            ("MCP".to_string(), target)
733        },
734        _ => (name.to_string(), String::new()),
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use crate::domain::{ManagedProcess, ManagedProcessStatus, ToolMetadata, ToolRunMetadata};
742    use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
743
744    fn sample_call(id: u64, name: &str) -> PendingToolCall {
745        sample_call_args(id, name, serde_json::json!({}))
746    }
747
748    fn sample_call_args(id: u64, name: &str, arguments: serde_json::Value) -> PendingToolCall {
749        PendingToolCall {
750            call_id: ToolCallId(id),
751            source: ModelToolCall {
752                id: Some(format!("c{}", id)),
753                function: FunctionCall {
754                    name: name.to_string(),
755                    arguments,
756                },
757            },
758        }
759    }
760
761    #[test]
762    fn ask_user_question_fallback_previews_the_summary() {
763        // Non-answered resolutions (dismissed / chat-about-this / headless)
764        // carry no `Questions` metadata; the transcript shows the summary
765        // instead of a bare duration.
766        let call = sample_call(1, "ask_user_question");
767        let outcome = ToolOutcome::success(
768            "The user dismissed the question(s) without answering.",
769            "dismissed without answering",
770            4.0,
771        );
772        let action = action_display_for(&call, &outcome);
773        match &action.details {
774            ActionDetails::Preview { text, .. } => {
775                assert!(text.contains("dismissed without answering"), "got {text}");
776                assert!(text.contains("took 4.0s"), "got {text}");
777            },
778            other => panic!("expected Preview details, got {other:?}"),
779        }
780    }
781
782    #[test]
783    fn agent_action_reports_child_model_and_tokens() {
784        let call = sample_call_args(1, "agent", serde_json::json!({"description": "explore"}));
785        let usage = crate::models::TokenUsage::provider(9_000, 3_300);
786        let outcome = ToolOutcome::success("the report", "subagent completed", 62.0).with_metadata(
787            ToolRunMetadata {
788                detail: ToolMetadata::Subagent {
789                    model_id: "ollama/minimax-m3".to_string(),
790                    agent_id: "a3".to_string(),
791                },
792                token_usage: Some(usage),
793                ..Default::default()
794            },
795        );
796        let action = action_display_for(&call, &outcome);
797        match &action.details {
798            ActionDetails::Preview { text, .. } => {
799                assert!(!text.contains("Success"), "no Success prefix: {text}");
800                assert!(text.contains("12.3k tokens"), "got {text}");
801                assert!(text.contains("ollama/minimax-m3"), "got {text}");
802                assert!(text.contains("a3"), "the continuation handle shows: {text}");
803            },
804            other => panic!("expected Preview details, got {other:?}"),
805        }
806    }
807
808    #[test]
809    fn agent_action_without_usage_metadata_stays_plain() {
810        // Old recordings / providers that report no usage: no "0 tokens".
811        let call = sample_call_args(1, "agent", serde_json::json!({}));
812        let outcome = ToolOutcome::success("report", "subagent completed", 2.0);
813        let action = action_display_for(&call, &outcome);
814        match &action.details {
815            ActionDetails::Preview { text, .. } => {
816                assert!(!text.contains("tokens"), "got {text}");
817                assert!(text.contains("subagent finished"), "got {text}");
818            },
819            other => panic!("expected Preview details, got {other:?}"),
820        }
821    }
822
823    #[test]
824    fn action_display_read_reports_line_count_and_duration() {
825        let call = sample_call_args(1, "read_file", serde_json::json!({"path": "src/main.rs"}));
826        let action = action_display_for(
827            &call,
828            &ToolOutcome::success("one\ntwo\nthree\n", "3 lines read", 1.25),
829        );
830
831        assert_eq!(action.action_type, "Read");
832        match action.details {
833            ActionDetails::Preview { text, line_count } => {
834                assert_eq!(line_count, Some(3));
835                assert!(text.contains("3 lines read"));
836                assert!(!text.contains("Success"), "no Success prefix: {text}");
837                assert!(text.contains("took 1.2s"));
838            },
839            other => panic!("expected preview details, got {:?}", other),
840        }
841    }
842
843    #[test]
844    fn action_display_write_carries_display_diff_when_available() {
845        let call = sample_call_args(
846            1,
847            "write_file",
848            serde_json::json!({"path": "petal/index.html", "content": "a\nb\n"}),
849        );
850        let action = action_display_for(
851            &call,
852            &ToolOutcome::success("Wrote petal/index.html (2 lines)", "2 lines written", 0.05)
853                .with_metadata(ToolRunMetadata {
854                    detail: ToolMetadata::WriteFile {
855                        path: "petal/index.html".to_string(),
856                        line_count: 2,
857                        byte_count: 4,
858                        created: Some(true),
859                    },
860                    display_diff: Some("   1 + a\n   2 + b".to_string()),
861                    ..ToolRunMetadata::default()
862                }),
863        );
864
865        assert_eq!(
866            action.action_type, "Write",
867            "a newly created file reads as Write"
868        );
869        match action.details {
870            ActionDetails::Diff { summary, diff } => {
871                assert!(summary.contains("Added 2 lines"), "got {summary}");
872                assert!(!summary.contains("Success"), "no Success prefix: {summary}");
873                assert!(summary.contains(", took "), "timing is kept: {summary}");
874                assert!(diff.contains("+ a"));
875                assert!(!diff.contains("+++"), "no diff header clutter");
876            },
877            other => panic!("expected diff details, got {:?}", other),
878        }
879    }
880
881    #[test]
882    fn action_display_write_over_existing_file_is_labeled_update() {
883        // Overwriting a file that already existed is an update, not a fresh
884        // write — the tool reports `created: Some(false)` and the label follows.
885        let call = sample_call_args(
886            1,
887            "write_file",
888            serde_json::json!({"path": "metadata.json", "content": "{}\n"}),
889        );
890        let action = action_display_for(
891            &call,
892            &ToolOutcome::success("Wrote metadata.json (1 lines)", "1 line written", 0.05)
893                .with_metadata(ToolRunMetadata {
894                    detail: ToolMetadata::WriteFile {
895                        path: "metadata.json".to_string(),
896                        line_count: 1,
897                        byte_count: 3,
898                        created: Some(false),
899                    },
900                    display_diff: Some("   1 - old\n   1 + {}".to_string()),
901                    ..ToolRunMetadata::default()
902                }),
903        );
904
905        assert_eq!(
906            action.action_type, "Update",
907            "overwriting an existing file reads as Update"
908        );
909        assert_eq!(action.target, "metadata.json");
910        match action.details {
911            ActionDetails::Diff { summary, .. } => {
912                assert!(
913                    summary.contains("Added 1 line, removed 1 line"),
914                    "got {summary}"
915                );
916            },
917            other => panic!("expected diff details, got {:?}", other),
918        }
919    }
920
921    #[test]
922    fn format_line_changes_reads_like_claude_code() {
923        assert_eq!(format_line_changes(49, 0), "Added 49 lines");
924        assert_eq!(format_line_changes(1, 0), "Added 1 line");
925        assert_eq!(format_line_changes(0, 9), "Removed 9 lines");
926        assert_eq!(format_line_changes(0, 1), "Removed 1 line");
927        assert_eq!(format_line_changes(7, 1), "Added 7 lines, removed 1 line");
928        assert_eq!(format_line_changes(0, 0), "No changes");
929    }
930
931    #[test]
932    fn apply_patch_action_labels_by_operation() {
933        let files = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<String>>();
934        let no_files: Vec<String> = vec![];
935        let no_renames: Vec<(String, String)> = vec![];
936
937        // single-file patch → operation-specific verb + that file's path
938        assert_eq!(
939            apply_patch_action(&no_files, &files(&["a.rs"]), &no_files, &no_renames),
940            Some(("Update".to_string(), "a.rs".to_string()))
941        );
942        assert_eq!(
943            apply_patch_action(&files(&["new.rs"]), &no_files, &no_files, &no_renames),
944            Some(("Write".to_string(), "new.rs".to_string()))
945        );
946        assert_eq!(
947            apply_patch_action(&no_files, &no_files, &files(&["gone.rs"]), &no_renames),
948            Some(("Delete".to_string(), "gone.rs".to_string()))
949        );
950        assert_eq!(
951            apply_patch_action(
952                &no_files,
953                &no_files,
954                &no_files,
955                &[("old.rs".to_string(), "new.rs".to_string())]
956            ),
957            Some(("Update".to_string(), "new.rs".to_string()))
958        );
959        // multi-file patch → Update(N files)
960        assert_eq!(
961            apply_patch_action(
962                &files(&["a.rs"]),
963                &files(&["b.rs", "c.rs"]),
964                &no_files,
965                &no_renames
966            ),
967            Some(("Update".to_string(), "3 files".to_string()))
968        );
969        // empty patch → None (caller keeps the original label)
970        assert_eq!(
971            apply_patch_action(&no_files, &no_files, &no_files, &no_renames),
972            None
973        );
974    }
975
976    #[test]
977    fn action_display_apply_patch_carries_display_diff_when_available() {
978        let call = sample_call_args(
979            1,
980            "apply_patch",
981            serde_json::json!({"patch": "*** Begin Patch\n*** Update File: src/main.rs\n-old\n+new\n*** End Patch"}),
982        );
983        let action = action_display_for(
984            &call,
985            &ToolOutcome::success("Applied patch: 1 file(s)\nM src/main.rs", "+1 -1", 0.05)
986                .with_metadata(ToolRunMetadata {
987                    detail: ToolMetadata::ApplyPatch {
988                        added: vec![],
989                        modified: vec!["src/main.rs".to_string()],
990                        deleted: vec![],
991                        renamed: vec![],
992                        fuzzy: false,
993                    },
994                    display_diff: Some("=== M src/main.rs ===\n   1 - old\n   1 + new".to_string()),
995                    ..ToolRunMetadata::default()
996                }),
997        );
998
999        assert_eq!(action.action_type, "Update");
1000        assert_eq!(action.target, "src/main.rs");
1001        match action.details {
1002            ActionDetails::Diff { diff, .. } => {
1003                assert!(diff.contains("- old"));
1004                assert!(diff.contains("+ new"));
1005            },
1006            other => panic!("expected diff details, got {:?}", other),
1007        }
1008    }
1009
1010    #[test]
1011    fn action_display_web_search_reports_partial_backend_and_truncation() {
1012        let call = sample_call_args(
1013            1,
1014            "web_search",
1015            serde_json::json!({"queries": [{"query": "rust"}, {"query": "systems"}]}),
1016        );
1017        let output = "[SEARCH_RESULTS]\n[1] Title: A\nURL: https://a.test\nContent:\nA\n---\n[2] Title: B\nURL: https://b.test\nContent:\nB\n---\n";
1018        let outcome = ToolOutcome::success(output, "2 results returned", 15.2).with_metadata(
1019            ToolRunMetadata {
1020                detail: ToolMetadata::WebSearch {
1021                    queries: vec!["rust".to_string(), "systems".to_string()],
1022                    requested_count: 10,
1023                    result_count: 2,
1024                    sources: vec!["https://a.test".to_string(), "https://b.test".to_string()],
1025                    backend: "mock".to_string(),
1026                    succeeded_queries: 1,
1027                    failed_queries: 1,
1028                    partial: true,
1029                    truncated: true,
1030                    failures: vec![crate::domain::WebSearchFailure {
1031                        query_index: 1,
1032                        error: "upstream timed out".to_string(),
1033                    }],
1034                },
1035                result_count: Some(2),
1036                ..ToolRunMetadata::default()
1037            },
1038        );
1039        let action = action_display_for(&call, &outcome);
1040
1041        match action.details {
1042            ActionDetails::Preview { text, .. } => {
1043                assert!(text.contains("2 results returned"));
1044                assert!(text.contains("via mock"));
1045                assert!(text.contains("1 failed"));
1046                assert!(text.contains("truncated"));
1047                assert!(!text.contains("Success"), "no Success prefix: {text}");
1048                assert!(text.contains("took 15s"));
1049            },
1050            other => panic!("expected preview details, got {:?}", other),
1051        }
1052        let metadata = action.metadata.expect("metadata");
1053        assert_eq!(metadata.result_count, Some(2));
1054        assert_eq!(metadata.duration_secs, Some(15.2));
1055    }
1056
1057    #[test]
1058    fn action_display_web_fetch_surfaces_safe_provenance() {
1059        let call = sample_call_args(
1060            1,
1061            "web_fetch",
1062            serde_json::json!({
1063                "url": "https://example.test/page?token=opaque-secret#private"
1064            }),
1065        );
1066        let outcome =
1067            ToolOutcome::success("page", "4 lines fetched", 1.2).with_metadata(ToolRunMetadata {
1068                detail: ToolMetadata::WebFetch {
1069                    url: "https://example.test/page?token=%5BREDACTED%5D".to_string(),
1070                    final_url: Some("https://example.test/final".to_string()),
1071                    status: Some(200),
1072                    error_kind: None,
1073                    media_type: Some("text/html".to_string()),
1074                    charset: Some("utf-8".to_string()),
1075                    backend: "native".to_string(),
1076                    extraction: "readability".to_string(),
1077                    title: Some("Example".to_string()),
1078                    line_count: 4,
1079                    byte_count: 128,
1080                    source_byte_count: 512,
1081                    output_byte_count: 384,
1082                    truncated: true,
1083                    pattern: Some("OPENAI_API_KEY=abc".to_string()),
1084                    context_lines: Some(2),
1085                    match_count: Some(1),
1086                    snapshot_id: Some("web-1".to_string()),
1087                },
1088                line_count: Some(4),
1089                ..ToolRunMetadata::default()
1090            });
1091        let action = action_display_for(&call, &outcome);
1092        assert!(!action.target.contains("opaque-secret"));
1093        assert!(!action.target.contains("private"));
1094        match action.details {
1095            ActionDetails::Preview { text, .. } => {
1096                assert!(text.contains("via native"));
1097                assert!(text.contains("HTTP 200"));
1098                assert!(text.contains("readability"));
1099                assert!(text.contains("384 extracted bytes"));
1100                assert!(text.contains("final https://example.test/final"));
1101                assert!(text.contains("truncated"));
1102                assert!(text.contains("web-1"));
1103                assert!(!text.contains("OPENAI_API_KEY=abc"));
1104                assert!(text.contains("OPENAI_API_KEY=[REDACTED]"));
1105            },
1106            other => panic!("expected preview details, got {other:?}"),
1107        }
1108    }
1109
1110    #[test]
1111    fn action_display_background_command_surfaces_pid_and_log() {
1112        let call = sample_call_args(
1113            1,
1114            "execute_command",
1115            serde_json::json!({"command": "npm run dev", "mode": "background"}),
1116        );
1117        let output = "Background command started.\nPID: 123\nLog: /tmp/mermaid-bg.log\nReady: matched pattern \"Local:\"\nDetected URL: http://127.0.0.1:5173\n\n--- startup output ---\nLocal: http://127.0.0.1:5173";
1118        let outcome = ToolOutcome::success(output, "background process started", 0.8)
1119            .with_metadata(ToolRunMetadata {
1120                detail: ToolMetadata::ExecuteCommand {
1121                    command: "npm run dev".to_string(),
1122                    working_dir: None,
1123                    exit_code: None,
1124                    timed_out: false,
1125                    background: true,
1126                    stdout_lines: 1,
1127                    stderr_lines: 0,
1128                    detected_urls: vec!["http://127.0.0.1:5173".to_string()],
1129                    pid: Some(123),
1130                    log_path: Some("/tmp/mermaid-bg.log".to_string()),
1131                    denied_by_sandbox: false,
1132                },
1133                process: Some(ManagedProcess {
1134                    id: "bg-123".to_string(),
1135                    pid: 123,
1136                    command: "npm run dev".to_string(),
1137                    cwd: None,
1138                    log_path: "/tmp/mermaid-bg.log".to_string(),
1139                    detected_url: Some("http://127.0.0.1:5173".to_string()),
1140                    status: ManagedProcessStatus::Running,
1141                }),
1142                ..ToolRunMetadata::default()
1143            });
1144        let action = action_display_for(&call, &outcome);
1145
1146        match action.details {
1147            ActionDetails::Preview { text, .. } => {
1148                assert!(text.contains("background process started"));
1149                assert!(!text.contains("Success"), "no Success prefix: {text}");
1150                assert!(text.contains("PID: 123"));
1151                assert!(text.contains("Log: /tmp/mermaid-bg.log"));
1152                assert!(text.contains("Detected URL: http://127.0.0.1:5173"));
1153                assert!(!text.contains("startup output"));
1154            },
1155            other => panic!("expected preview details, got {:?}", other),
1156        }
1157        let metadata = action.metadata.expect("metadata");
1158        let process = metadata.process.expect("process metadata");
1159        assert_eq!(process.id, "bg-123");
1160        assert_eq!(process.pid, 123);
1161        assert_eq!(process.command, "npm run dev");
1162        assert_eq!(
1163            process.detected_url.as_deref(),
1164            Some("http://127.0.0.1:5173")
1165        );
1166    }
1167
1168    #[test]
1169    fn try_complete_outcomes_returns_none_on_incomplete() {
1170        let outcomes = vec![Some(ToolOutcome::success("a", "a", 0.1)), None];
1171        assert!(try_complete_outcomes(&outcomes).is_none());
1172    }
1173
1174    #[test]
1175    fn try_complete_outcomes_returns_vec_on_complete() {
1176        let outcomes = vec![
1177            Some(ToolOutcome::success("a", "a", 0.1)),
1178            Some(ToolOutcome::cancelled()),
1179        ];
1180        let result = try_complete_outcomes(&outcomes);
1181        assert!(result.is_some());
1182        assert_eq!(result.unwrap().len(), 2);
1183    }
1184
1185    #[test]
1186    fn fill_outcome_writes_to_correct_slot() {
1187        let calls = vec![sample_call(1, "read_file"), sample_call(2, "write_file")];
1188        let mut outcomes = vec![None, None];
1189
1190        let wrote = fill_outcome(
1191            &calls,
1192            &mut outcomes,
1193            ToolCallId(2),
1194            ToolOutcome::cancelled(),
1195        );
1196        assert!(wrote);
1197        assert!(outcomes[0].is_none());
1198        assert!(outcomes[1].is_some());
1199    }
1200
1201    #[test]
1202    fn fill_outcome_stale_call_id_returns_false() {
1203        let calls = vec![sample_call(1, "read_file")];
1204        let mut outcomes = vec![None];
1205        let wrote = fill_outcome(
1206            &calls,
1207            &mut outcomes,
1208            ToolCallId(999),
1209            ToolOutcome::cancelled(),
1210        );
1211        assert!(!wrote);
1212        assert!(outcomes[0].is_none());
1213    }
1214
1215    #[test]
1216    fn fill_outcome_duplicate_write_ignored() {
1217        let calls = vec![sample_call(1, "read_file")];
1218        let mut outcomes = vec![Some(ToolOutcome::success("first", "first", 0.0))];
1219        let wrote = fill_outcome(
1220            &calls,
1221            &mut outcomes,
1222            ToolCallId(1),
1223            ToolOutcome::cancelled(),
1224        );
1225        assert!(!wrote);
1226        match &outcomes[0] {
1227            Some(outcome) if outcome.is_success() => assert_eq!(outcome.output(), "first"),
1228            _ => panic!("original outcome was overwritten"),
1229        }
1230    }
1231
1232    #[test]
1233    fn start_generating_produces_fresh_sending_phase() {
1234        let s = start_generating(TurnId(1), SystemTime::now());
1235        match s {
1236            TurnState::Generating {
1237                phase,
1238                tokens,
1239                partial_text,
1240                ..
1241            } => {
1242                assert_eq!(phase, GenPhase::Sending);
1243                assert_eq!(tokens, 0);
1244                assert!(partial_text.is_empty());
1245            },
1246            _ => panic!("expected Generating"),
1247        }
1248    }
1249
1250    #[test]
1251    fn start_executing_tools_allocates_outcome_slots() {
1252        let calls = vec![
1253            sample_call(1, "a"),
1254            sample_call(2, "b"),
1255            sample_call(3, "c"),
1256        ];
1257        let s = start_executing_tools(TurnId(1), calls, SystemTime::now());
1258        match s {
1259            TurnState::ExecutingTools {
1260                outcomes, calls, ..
1261            } => {
1262                assert_eq!(outcomes.len(), 3);
1263                assert_eq!(calls.len(), 3);
1264                assert!(outcomes.iter().all(|o| o.is_none()));
1265            },
1266            _ => panic!("expected ExecutingTools"),
1267        }
1268    }
1269
1270    #[test]
1271    fn commit_assistant_message_preserves_provider_continuation() {
1272        let m = commit_assistant_message(
1273            "hello".to_string(),
1274            "reasoning".to_string(),
1275            vec![],
1276            Some(ProviderContinuation::Anthropic {
1277                signature: "sig_abc".to_string(),
1278            }),
1279            chrono::Local::now(),
1280            false,
1281        );
1282        assert_eq!(m.content, "hello");
1283        assert_eq!(m.thinking.as_deref(), Some("reasoning"));
1284        assert_eq!(
1285            m.provider_continuation
1286                .as_ref()
1287                .and_then(ProviderContinuation::anthropic_signature),
1288            Some("sig_abc")
1289        );
1290    }
1291
1292    #[test]
1293    fn commit_assistant_message_empty_reasoning_is_none() {
1294        let m = commit_assistant_message(
1295            "hi".to_string(),
1296            String::new(),
1297            vec![],
1298            None,
1299            chrono::Local::now(),
1300            false,
1301        );
1302        assert!(m.thinking.is_none());
1303        assert_eq!(m.kind, crate::models::ChatMessageKind::Normal);
1304    }
1305
1306    #[test]
1307    fn commit_assistant_message_stamps_continuation_kind() {
1308        let m = commit_assistant_message(
1309            "resumed text".to_string(),
1310            String::new(),
1311            vec![],
1312            None,
1313            chrono::Local::now(),
1314            true,
1315        );
1316        assert_eq!(m.kind, crate::models::ChatMessageKind::Continuation);
1317    }
1318
1319    #[test]
1320    fn start_generating_with_carries_the_continuation_flag() {
1321        let t = start_generating_with(TurnId(3), std::time::SystemTime::now(), true);
1322        assert!(matches!(
1323            t,
1324            TurnState::Generating {
1325                continuation: true,
1326                ..
1327            }
1328        ));
1329        // The plain constructor stays a non-continuation turn.
1330        let t = start_generating(TurnId(4), std::time::SystemTime::now());
1331        assert!(matches!(
1332            t,
1333            TurnState::Generating {
1334                continuation: false,
1335                ..
1336            }
1337        ));
1338    }
1339
1340    #[test]
1341    fn tool_result_messages_align_call_id_and_name() {
1342        let calls = vec![sample_call(1, "read_file"), sample_call(2, "write_file")];
1343        let outcomes = vec![
1344            ToolOutcome::success("contents", "contents", 0.1),
1345            ToolOutcome::cancelled(),
1346        ];
1347        let msgs = tool_result_messages(&calls, outcomes);
1348        assert_eq!(msgs.len(), 2);
1349        assert_eq!(msgs[0].role, MessageRole::Tool);
1350        assert_eq!(msgs[0].tool_call_id.as_deref(), Some("c1"));
1351        assert_eq!(msgs[0].tool_name.as_deref(), Some("read_file"));
1352        assert_eq!(msgs[0].content, "contents");
1353        assert!(msgs[1].content.contains("cancelled"));
1354    }
1355
1356    #[test]
1357    fn format_run_duration_scales() {
1358        assert_eq!(format_run_duration(0), "0s");
1359        assert_eq!(format_run_duration(5), "5s");
1360        assert_eq!(format_run_duration(59), "59s");
1361        assert_eq!(format_run_duration(72), "1m 12s");
1362        assert_eq!(format_run_duration(600), "10m 0s");
1363        assert_eq!(format_run_duration(3661), "1h 1m");
1364    }
1365}