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