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};
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    let mut out = Vec::with_capacity(outcomes.len());
33    for slot in outcomes {
34        match slot {
35            Some(o) => out.push(o.clone()),
36            None => return None,
37        }
38    }
39    Some(out)
40}
41
42/// Write the outcome for a specific tool call ID into the slot
43/// carrying that call. Returns `true` if the slot was found and empty;
44/// `false` if the call isn't pending (stale event) or was already
45/// filled (duplicate event — first write wins).
46pub fn fill_outcome(
47    calls: &[PendingToolCall],
48    outcomes: &mut [Option<ToolOutcome>],
49    call_id: ToolCallId,
50    outcome: ToolOutcome,
51) -> bool {
52    debug_assert_eq!(
53        calls.len(),
54        outcomes.len(),
55        "calls and outcomes must be aligned"
56    );
57    let Some(idx) = calls.iter().position(|c| c.call_id == call_id) else {
58        return false;
59    };
60    if outcomes[idx].is_some() {
61        return false;
62    }
63    outcomes[idx] = Some(outcome);
64    true
65}
66
67/// Transition `Idle → Generating`. Always pure: the caller builds a
68/// `ChatRequest` separately and returns it to the reducer as a `Cmd`.
69/// `now` is the reducer step's injected clock (`state.now`), so the
70/// `started` stamp is deterministic on replay rather than read live (Cause 3).
71pub fn start_generating(id: TurnId, now: SystemTime) -> TurnState {
72    TurnState::Generating {
73        id,
74        started: now,
75        partial_text: String::new(),
76        partial_reasoning: String::new(),
77        tokens: 0,
78        phase: GenPhase::Sending,
79        thinking_signature: None,
80        pending_tool_calls: Vec::new(),
81    }
82}
83
84/// Transition `Generating → ExecutingTools`. Allocates `None` slots
85/// for every call so the invariant ("outcomes.len() == calls.len()")
86/// is upheld by construction. `now` is the reducer step's injected clock
87/// (`state.now`) so `started` is deterministic on replay (Cause 3).
88pub fn start_executing_tools(
89    id: TurnId,
90    calls: Vec<PendingToolCall>,
91    now: SystemTime,
92) -> TurnState {
93    let outcomes = vec![None; calls.len()];
94    TurnState::ExecutingTools {
95        id,
96        started: now,
97        calls,
98        outcomes,
99    }
100}
101
102/// Build the committed assistant message from a `Generating` state's
103/// accumulated content. Safe to call with empty text (the model might
104/// have responded with only tool calls). Returns the message plus the
105/// thinking signature so the reducer can record it separately for
106/// Anthropic round-trip.
107pub fn commit_assistant_message(
108    partial_text: String,
109    partial_reasoning: String,
110    tool_calls: Vec<ModelToolCall>,
111    thinking_signature: Option<String>,
112    now: chrono::DateTime<chrono::Local>,
113) -> ChatMessage {
114    let thinking = if partial_reasoning.is_empty() {
115        None
116    } else {
117        Some(partial_reasoning)
118    };
119    let mut msg = ChatMessage {
120        role: MessageRole::Assistant,
121        content: partial_text,
122        timestamp: now,
123        kind: crate::models::ChatMessageKind::Normal,
124        metadata: None,
125        actions: Vec::new(),
126        thinking,
127        images: None,
128        tool_calls: if tool_calls.is_empty() {
129            None
130        } else {
131            Some(tool_calls)
132        },
133        tool_call_id: None,
134        tool_name: None,
135        thinking_signature: None,
136    };
137    if let Some(sig) = thinking_signature {
138        msg = msg.with_thinking_signature(sig);
139    }
140    msg
141}
142
143/// Build the follow-up `tool` role messages from completed outcomes.
144/// The OpenAI-compatible wire format requires (tool_call_id, tool_name,
145/// content) — we pull name from the original call.
146pub fn tool_result_messages(
147    calls: &[PendingToolCall],
148    outcomes: Vec<ToolOutcome>,
149) -> Vec<ChatMessage> {
150    debug_assert_eq!(calls.len(), outcomes.len());
151    calls
152        .iter()
153        .zip(outcomes)
154        .map(|(call, outcome)| {
155            let tool_call_id = call
156                .source
157                .id
158                .clone()
159                .unwrap_or_else(|| format!("call_{}", call.call_id.0));
160            ChatMessage::tool(
161                tool_call_id,
162                call.source.function.name.clone(),
163                outcome.as_tool_message_content(),
164            )
165        })
166        .collect()
167}
168
169/// Convert a completed tool outcome into an `ActionDisplay` entry
170/// attached to the assistant message that triggered the call. Used so
171/// the chat renderer can show "Read main.rs → 1,234 bytes" etc.
172pub fn action_display_for(call: &PendingToolCall, outcome: &ToolOutcome) -> ActionDisplay {
173    let (action_type, target) = display_info_for(call);
174    let duration = outcome.duration_secs;
175    let result = if outcome.is_success() {
176        ActionResult::Success {
177            output: outcome.output().to_string(),
178            images: outcome.images(),
179        }
180    } else {
181        ActionResult::Error {
182            error: outcome.error_message().unwrap_or("[cancelled]").to_string(),
183        }
184    };
185    let details = action_details_for(call, outcome, duration);
186    ActionDisplay {
187        action_type,
188        target,
189        result,
190        details,
191        duration_seconds: duration,
192        metadata: Some((*outcome.metadata).clone()),
193    }
194}
195
196fn action_details_for(
197    call: &PendingToolCall,
198    outcome: &ToolOutcome,
199    duration: Option<f64>,
200) -> ActionDetails {
201    if !outcome.is_success() {
202        return ActionDetails::Simple;
203    }
204
205    let name = call.source.function.name.as_str();
206    let args = &call.source.function.arguments;
207    match name {
208        "read_file" => {
209            let line_count = outcome
210                .metadata
211                .line_count
212                .or_else(|| metadata_line_count(&outcome.metadata.detail))
213                .unwrap_or_else(|| outcome.output().lines().count());
214            ActionDetails::Preview {
215                text: success_summary(
216                    format!("{} {} read", line_count, pluralize("line", line_count)),
217                    duration,
218                ),
219                line_count: Some(line_count),
220            }
221        },
222        "write_file" => {
223            let line_count = outcome
224                .metadata
225                .line_count
226                .or_else(|| metadata_line_count(&outcome.metadata.detail))
227                .or_else(|| {
228                    args.get("content")
229                        .and_then(|v| v.as_str())
230                        .map(|content| content.lines().count())
231                })
232                .unwrap_or(0);
233            if let Some(diff) = outcome.metadata.display_diff.clone() {
234                ActionDetails::Diff {
235                    summary: diff_success_summary(&diff, duration),
236                    diff,
237                }
238            } else {
239                let content = args
240                    .get("content")
241                    .and_then(|v| v.as_str())
242                    .unwrap_or_default()
243                    .to_string();
244                ActionDetails::FileContent {
245                    line_count,
246                    content,
247                }
248            }
249        },
250        "web_search" => {
251            let result_count = outcome
252                .metadata
253                .result_count
254                .or_else(|| metadata_result_count(&outcome.metadata.detail))
255                .unwrap_or_else(|| count_search_results(outcome.output()));
256            ActionDetails::Preview {
257                text: success_summary(
258                    format!(
259                        "{} {} returned",
260                        result_count,
261                        pluralize("result", result_count)
262                    ),
263                    duration,
264                ),
265                line_count: None,
266            }
267        },
268        "web_fetch" => {
269            let line_count = outcome
270                .metadata
271                .line_count
272                .or_else(|| metadata_line_count(&outcome.metadata.detail))
273                .unwrap_or_else(|| outcome.output().lines().count());
274            ActionDetails::Preview {
275                text: success_summary(
276                    format!("{} {} fetched", line_count, pluralize("line", line_count)),
277                    duration,
278                ),
279                line_count: Some(line_count),
280            }
281        },
282        "execute_command" => ActionDetails::Preview {
283            text: command_success_summary(outcome, duration),
284            line_count: outcome
285                .metadata
286                .line_count
287                .or_else(|| metadata_line_count(&outcome.metadata.detail))
288                .or_else(|| Some(outcome.output().lines().count())),
289        },
290        "edit_file" => {
291            if let Some(diff) = outcome.metadata.display_diff.clone() {
292                ActionDetails::Diff {
293                    summary: diff_success_summary(&diff, duration),
294                    diff,
295                }
296            } else {
297                ActionDetails::Preview {
298                    text: success_summary(outcome.summary.clone(), duration),
299                    line_count: None,
300                }
301            }
302        },
303        _ => ActionDetails::Simple,
304    }
305}
306
307fn diff_success_summary(diff: &str, duration: Option<f64>) -> String {
308    let (added, removed) = diff_counts(diff);
309    success_summary(format!("+{} -{}", added, removed), duration)
310}
311
312fn diff_counts(diff: &str) -> (usize, usize) {
313    let mut added = 0usize;
314    let mut removed = 0usize;
315    for line in diff.lines() {
316        match crate::render::diff::parse_diff_line(line) {
317            crate::render::diff::DiffLineKind::Added => added += 1,
318            crate::render::diff::DiffLineKind::Removed => removed += 1,
319            crate::render::diff::DiffLineKind::Context => {},
320        }
321    }
322    (added, removed)
323}
324
325fn metadata_line_count(metadata: &ToolMetadata) -> Option<usize> {
326    match metadata {
327        ToolMetadata::ReadFile { line_count, .. }
328        | ToolMetadata::WriteFile { line_count, .. }
329        | ToolMetadata::WebFetch { line_count, .. } => Some(*line_count),
330        ToolMetadata::ExecuteCommand {
331            stdout_lines,
332            stderr_lines,
333            ..
334        } => Some(stdout_lines + stderr_lines),
335        _ => None,
336    }
337}
338
339fn metadata_result_count(metadata: &ToolMetadata) -> Option<usize> {
340    match metadata {
341        ToolMetadata::WebSearch { result_count, .. } => Some(*result_count),
342        _ => None,
343    }
344}
345
346fn success_summary(detail: String, duration: Option<f64>) -> String {
347    match duration {
348        Some(seconds) => format!("Success, {}, took {}", detail, format_duration(seconds)),
349        None => format!("Success, {}", detail),
350    }
351}
352
353fn command_success_summary(outcome: &ToolOutcome, duration: Option<f64>) -> String {
354    if outcome.metadata.process.is_none() {
355        return success_summary("command completed".to_string(), duration);
356    }
357
358    let mut lines = vec![success_summary(
359        "background process started".to_string(),
360        duration,
361    )];
362    for line in outcome.output().lines().skip(1) {
363        if line.starts_with("--- startup output ---") {
364            break;
365        }
366        if !line.trim().is_empty() {
367            lines.push(line.to_string());
368        }
369    }
370    lines.join("\n")
371}
372
373fn format_duration(seconds: f64) -> String {
374    if seconds < 1.0 {
375        format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
376    } else if seconds < 10.0 {
377        format!("{:.1}s", seconds)
378    } else {
379        format!("{}s", seconds.round() as u64)
380    }
381}
382
383/// Human-readable duration for a finished run: "Ns", "Mm Ss", or "Hh Mm".
384pub fn format_run_duration(seconds: u64) -> String {
385    if seconds < 60 {
386        format!("{seconds}s")
387    } else if seconds < 3600 {
388        format!("{}m {}s", seconds / 60, seconds % 60)
389    } else {
390        format!("{}h {}m", seconds / 3600, (seconds % 3600) / 60)
391    }
392}
393
394fn pluralize(word: &str, count: usize) -> String {
395    if count == 1 {
396        word.to_string()
397    } else {
398        format!("{}s", word)
399    }
400}
401
402fn count_search_results(output: &str) -> usize {
403    output
404        .lines()
405        .filter(|line| line.starts_with('[') && line.contains("] Title:"))
406        .count()
407}
408
409/// Best-effort name + target extraction from a tool call, for chat
410/// display ("Read src/main.rs", "Bash cargo test", etc). Matches on
411/// the wire-format tool name + arguments; unknown tools fall through
412/// to the raw function name.
413pub fn display_info_for(call: &PendingToolCall) -> (String, String) {
414    let name = call.source.function.name.as_str();
415    let args = &call.source.function.arguments;
416    let string_arg =
417        |k: &str| -> Option<String> { args.get(k).and_then(|v| v.as_str()).map(str::to_string) };
418    match name {
419        "read_file" => {
420            let target = string_arg("path")
421                .or_else(|| {
422                    args.get("paths")
423                        .and_then(|v| v.as_array())
424                        .map(|a| match a.len() {
425                            0 => "(no paths)".to_string(),
426                            1 => a[0].as_str().unwrap_or("").to_string(),
427                            n => format!("{} files", n),
428                        })
429                })
430                .unwrap_or_default();
431            ("Read".to_string(), target)
432        },
433        "write_file" => ("Write".to_string(), string_arg("path").unwrap_or_default()),
434        "edit_file" => ("Edit".to_string(), string_arg("path").unwrap_or_default()),
435        "delete_file" => ("Delete".to_string(), string_arg("path").unwrap_or_default()),
436        "create_directory" => (
437            "Bash".to_string(),
438            format!("mkdir -p {}", string_arg("path").unwrap_or_default()),
439        ),
440        "execute_command" => (
441            "Bash".to_string(),
442            string_arg("command").unwrap_or_default(),
443        ),
444        "web_search" => {
445            let target = string_arg("query")
446                .or_else(|| {
447                    args.get("queries")
448                        .and_then(|v| v.as_array())
449                        .map(|a| match a.len() {
450                            0 => "(no queries)".to_string(),
451                            1 => a[0]
452                                .get("query")
453                                .and_then(|q| q.as_str())
454                                .unwrap_or("")
455                                .to_string(),
456                            n => format!("{} queries", n),
457                        })
458                })
459                .unwrap_or_default();
460            ("Web Search".to_string(), target)
461        },
462        "web_fetch" => (
463            "Web Fetch".to_string(),
464            string_arg("url").unwrap_or_default(),
465        ),
466        "memory" => {
467            let action = string_arg("action").unwrap_or_default();
468            let target = string_arg("id")
469                .or_else(|| string_arg("name"))
470                .unwrap_or_default();
471            let scope = if args
472                .get("global")
473                .and_then(|v| v.as_bool())
474                .unwrap_or(false)
475            {
476                " [global]"
477            } else if args
478                .get("shared")
479                .and_then(|v| v.as_bool())
480                .unwrap_or(false)
481            {
482                " [shared]"
483            } else {
484                ""
485            };
486            (
487                "Memory".to_string(),
488                format!("{action} {target}{scope}").trim().to_string(),
489            )
490        },
491        "agent" => (
492            "Agent".to_string(),
493            string_arg("description").unwrap_or_default(),
494        ),
495        n if n.starts_with("mcp__") => {
496            let rest = &n[5..];
497            let target = rest.replacen("__", ":", 1);
498            ("MCP".to_string(), target)
499        },
500        _ => (name.to_string(), String::new()),
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::domain::{ManagedProcess, ManagedProcessStatus, ToolMetadata, ToolRunMetadata};
508    use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
509
510    fn sample_call(id: u64, name: &str) -> PendingToolCall {
511        sample_call_args(id, name, serde_json::json!({}))
512    }
513
514    fn sample_call_args(id: u64, name: &str, arguments: serde_json::Value) -> PendingToolCall {
515        PendingToolCall {
516            call_id: ToolCallId(id),
517            source: ModelToolCall {
518                id: Some(format!("c{}", id)),
519                function: FunctionCall {
520                    name: name.to_string(),
521                    arguments,
522                },
523            },
524        }
525    }
526
527    #[test]
528    fn action_display_read_reports_line_count_and_duration() {
529        let call = sample_call_args(1, "read_file", serde_json::json!({"path": "src/main.rs"}));
530        let action = action_display_for(
531            &call,
532            &ToolOutcome::success("one\ntwo\nthree\n", "3 lines read", 1.25),
533        );
534
535        assert_eq!(action.action_type, "Read");
536        match action.details {
537            ActionDetails::Preview { text, line_count } => {
538                assert_eq!(line_count, Some(3));
539                assert!(text.contains("Success, 3 lines read"));
540                assert!(text.contains("took 1.2s"));
541            },
542            other => panic!("expected preview details, got {:?}", other),
543        }
544    }
545
546    #[test]
547    fn action_display_write_carries_display_diff_when_available() {
548        let call = sample_call_args(
549            1,
550            "write_file",
551            serde_json::json!({"path": "petal/index.html", "content": "a\nb\n"}),
552        );
553        let action = action_display_for(
554            &call,
555            &ToolOutcome::success("Wrote petal/index.html (2 lines)", "2 lines written", 0.05)
556                .with_metadata(ToolRunMetadata {
557                    detail: ToolMetadata::WriteFile {
558                        path: "petal/index.html".to_string(),
559                        line_count: 2,
560                        byte_count: 4,
561                        created: Some(true),
562                    },
563                    display_diff: Some("   1 + a\n   2 + b".to_string()),
564                    ..ToolRunMetadata::default()
565                }),
566        );
567
568        match action.details {
569            ActionDetails::Diff { summary, diff } => {
570                assert!(summary.contains("+2 -0"));
571                assert!(diff.contains("+ a"));
572                assert!(!diff.contains("+++"), "no diff header clutter");
573            },
574            other => panic!("expected diff details, got {:?}", other),
575        }
576    }
577
578    #[test]
579    fn action_display_edit_carries_display_diff_when_available() {
580        let call = sample_call_args(
581            1,
582            "edit_file",
583            serde_json::json!({"path": "src/main.rs", "old_string": "old", "new_string": "new"}),
584        );
585        let action = action_display_for(
586            &call,
587            &ToolOutcome::success("Edited src/main.rs", "+1 -1", 0.05).with_metadata(
588                ToolRunMetadata {
589                    detail: ToolMetadata::EditFile {
590                        path: "src/main.rs".to_string(),
591                        replacements: 1,
592                    },
593                    display_diff: Some("   1 - old\n   1 + new".to_string()),
594                    ..ToolRunMetadata::default()
595                },
596            ),
597        );
598
599        match action.details {
600            ActionDetails::Diff { summary, diff } => {
601                assert!(summary.contains("+1 -1"));
602                assert!(diff.contains("- old"));
603                assert!(diff.contains("+ new"));
604            },
605            other => panic!("expected diff details, got {:?}", other),
606        }
607    }
608
609    #[test]
610    fn action_display_web_search_reports_result_count() {
611        let call = sample_call_args(1, "web_search", serde_json::json!({"query": "rust"}));
612        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";
613        let outcome = ToolOutcome::success(output, "2 results returned", 15.2).with_metadata(
614            ToolRunMetadata {
615                detail: ToolMetadata::WebSearch {
616                    queries: vec!["rust".to_string()],
617                    requested_count: 5,
618                    result_count: 2,
619                    sources: vec!["https://a.test".to_string(), "https://b.test".to_string()],
620                },
621                result_count: Some(2),
622                ..ToolRunMetadata::default()
623            },
624        );
625        let action = action_display_for(&call, &outcome);
626
627        match action.details {
628            ActionDetails::Preview { text, .. } => {
629                assert!(text.contains("Success, 2 results returned"));
630                assert!(text.contains("took 15s"));
631            },
632            other => panic!("expected preview details, got {:?}", other),
633        }
634        let metadata = action.metadata.expect("metadata");
635        assert_eq!(metadata.result_count, Some(2));
636        assert_eq!(metadata.duration_secs, Some(15.2));
637    }
638
639    #[test]
640    fn action_display_background_command_surfaces_pid_and_log() {
641        let call = sample_call_args(
642            1,
643            "execute_command",
644            serde_json::json!({"command": "npm run dev", "mode": "background"}),
645        );
646        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";
647        let outcome = ToolOutcome::success(output, "background process started", 0.8)
648            .with_metadata(ToolRunMetadata {
649                detail: ToolMetadata::ExecuteCommand {
650                    command: "npm run dev".to_string(),
651                    working_dir: None,
652                    exit_code: None,
653                    timed_out: false,
654                    background: true,
655                    stdout_lines: 1,
656                    stderr_lines: 0,
657                    detected_urls: vec!["http://127.0.0.1:5173".to_string()],
658                    pid: Some(123),
659                    log_path: Some("/tmp/mermaid-bg.log".to_string()),
660                },
661                process: Some(ManagedProcess {
662                    id: "bg-123".to_string(),
663                    pid: 123,
664                    command: "npm run dev".to_string(),
665                    cwd: None,
666                    log_path: "/tmp/mermaid-bg.log".to_string(),
667                    detected_url: Some("http://127.0.0.1:5173".to_string()),
668                    status: ManagedProcessStatus::Running,
669                }),
670                ..ToolRunMetadata::default()
671            });
672        let action = action_display_for(&call, &outcome);
673
674        match action.details {
675            ActionDetails::Preview { text, .. } => {
676                assert!(text.contains("Success, background process started"));
677                assert!(text.contains("PID: 123"));
678                assert!(text.contains("Log: /tmp/mermaid-bg.log"));
679                assert!(text.contains("Detected URL: http://127.0.0.1:5173"));
680                assert!(!text.contains("startup output"));
681            },
682            other => panic!("expected preview details, got {:?}", other),
683        }
684        let metadata = action.metadata.expect("metadata");
685        let process = metadata.process.expect("process metadata");
686        assert_eq!(process.id, "bg-123");
687        assert_eq!(process.pid, 123);
688        assert_eq!(process.command, "npm run dev");
689        assert_eq!(
690            process.detected_url.as_deref(),
691            Some("http://127.0.0.1:5173")
692        );
693    }
694
695    #[test]
696    fn try_complete_outcomes_returns_none_on_incomplete() {
697        let outcomes = vec![Some(ToolOutcome::success("a", "a", 0.1)), None];
698        assert!(try_complete_outcomes(&outcomes).is_none());
699    }
700
701    #[test]
702    fn try_complete_outcomes_returns_vec_on_complete() {
703        let outcomes = vec![
704            Some(ToolOutcome::success("a", "a", 0.1)),
705            Some(ToolOutcome::cancelled()),
706        ];
707        let result = try_complete_outcomes(&outcomes);
708        assert!(result.is_some());
709        assert_eq!(result.unwrap().len(), 2);
710    }
711
712    #[test]
713    fn fill_outcome_writes_to_correct_slot() {
714        let calls = vec![sample_call(1, "read_file"), sample_call(2, "write_file")];
715        let mut outcomes = vec![None, None];
716
717        let wrote = fill_outcome(
718            &calls,
719            &mut outcomes,
720            ToolCallId(2),
721            ToolOutcome::cancelled(),
722        );
723        assert!(wrote);
724        assert!(outcomes[0].is_none());
725        assert!(outcomes[1].is_some());
726    }
727
728    #[test]
729    fn fill_outcome_stale_call_id_returns_false() {
730        let calls = vec![sample_call(1, "read_file")];
731        let mut outcomes = vec![None];
732        let wrote = fill_outcome(
733            &calls,
734            &mut outcomes,
735            ToolCallId(999),
736            ToolOutcome::cancelled(),
737        );
738        assert!(!wrote);
739        assert!(outcomes[0].is_none());
740    }
741
742    #[test]
743    fn fill_outcome_duplicate_write_ignored() {
744        let calls = vec![sample_call(1, "read_file")];
745        let mut outcomes = vec![Some(ToolOutcome::success("first", "first", 0.0))];
746        let wrote = fill_outcome(
747            &calls,
748            &mut outcomes,
749            ToolCallId(1),
750            ToolOutcome::cancelled(),
751        );
752        assert!(!wrote);
753        match &outcomes[0] {
754            Some(outcome) if outcome.is_success() => assert_eq!(outcome.output(), "first"),
755            _ => panic!("original outcome was overwritten"),
756        }
757    }
758
759    #[test]
760    fn start_generating_produces_fresh_sending_phase() {
761        let s = start_generating(TurnId(1), SystemTime::now());
762        match s {
763            TurnState::Generating {
764                phase,
765                tokens,
766                partial_text,
767                ..
768            } => {
769                assert_eq!(phase, GenPhase::Sending);
770                assert_eq!(tokens, 0);
771                assert!(partial_text.is_empty());
772            },
773            _ => panic!("expected Generating"),
774        }
775    }
776
777    #[test]
778    fn start_executing_tools_allocates_outcome_slots() {
779        let calls = vec![
780            sample_call(1, "a"),
781            sample_call(2, "b"),
782            sample_call(3, "c"),
783        ];
784        let s = start_executing_tools(TurnId(1), calls, SystemTime::now());
785        match s {
786            TurnState::ExecutingTools {
787                outcomes, calls, ..
788            } => {
789                assert_eq!(outcomes.len(), 3);
790                assert_eq!(calls.len(), 3);
791                assert!(outcomes.iter().all(|o| o.is_none()));
792            },
793            _ => panic!("expected ExecutingTools"),
794        }
795    }
796
797    #[test]
798    fn commit_assistant_message_preserves_thinking_signature() {
799        let m = commit_assistant_message(
800            "hello".to_string(),
801            "reasoning".to_string(),
802            vec![],
803            Some("sig_abc".to_string()),
804            chrono::Local::now(),
805        );
806        assert_eq!(m.content, "hello");
807        assert_eq!(m.thinking.as_deref(), Some("reasoning"));
808        assert_eq!(m.thinking_signature.as_deref(), Some("sig_abc"));
809    }
810
811    #[test]
812    fn commit_assistant_message_empty_reasoning_is_none() {
813        let m = commit_assistant_message(
814            "hi".to_string(),
815            String::new(),
816            vec![],
817            None,
818            chrono::Local::now(),
819        );
820        assert!(m.thinking.is_none());
821    }
822
823    #[test]
824    fn tool_result_messages_align_call_id_and_name() {
825        let calls = vec![sample_call(1, "read_file"), sample_call(2, "write_file")];
826        let outcomes = vec![
827            ToolOutcome::success("contents", "contents", 0.1),
828            ToolOutcome::cancelled(),
829        ];
830        let msgs = tool_result_messages(&calls, outcomes);
831        assert_eq!(msgs.len(), 2);
832        assert_eq!(msgs[0].role, MessageRole::Tool);
833        assert_eq!(msgs[0].tool_call_id.as_deref(), Some("c1"));
834        assert_eq!(msgs[0].tool_name.as_deref(), Some("read_file"));
835        assert_eq!(msgs[0].content, "contents");
836        assert!(msgs[1].content.contains("cancelled"));
837    }
838
839    #[test]
840    fn format_run_duration_scales() {
841        assert_eq!(format_run_duration(0), "0s");
842        assert_eq!(format_run_duration(5), "5s");
843        assert_eq!(format_run_duration(59), "59s");
844        assert_eq!(format_run_duration(72), "1m 12s");
845        assert_eq!(format_run_duration(600), "10m 0s");
846        assert_eq!(format_run_duration(3661), "1h 1m");
847    }
848}