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