1use 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
21pub 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
42pub 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
67pub 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
84pub 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
102pub 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
143pub 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
169pub 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
383pub 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
409pub 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}