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) -> 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
82pub 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
95pub 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
135pub 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
161pub 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
390pub 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}