1use agent_client_protocol::schema::v1::{
4 SessionUpdate, ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdateFields, ToolKind,
5};
6use mj_core::acp::RuntimeEvent;
7pub use mj_core::transcript::*;
8use serde::Deserialize;
9use serde_json::Value;
10use tree_sitter::{Node, Parser};
11const TOOL_SUMMARY_SOURCE_BYTES: usize = 64 * 1024;
12pub const TOOL_SUMMARY_VERSION: u8 = 1;
14
15pub fn compact_tool_call_for_retention(body: &mut TranscriptBody) -> bool {
26 let TranscriptBody::Tool {
27 call,
28 terminal_outputs,
29 terminal_refs,
30 ..
31 } = body
32 else {
33 return false;
34 };
35 let Some(object) = call.as_object_mut() else {
36 return false;
37 };
38 let mut changed = !terminal_outputs.is_empty() || !terminal_refs.is_empty();
39 terminal_outputs.clear();
40 terminal_refs.clear();
41 for field in ["rawInput", "rawOutput", "_meta"] {
42 changed |= object.remove(field).is_some();
43 }
44 let Some(content) = object
45 .get_mut("content")
46 .and_then(|value| value.as_array_mut())
47 else {
48 return changed;
49 };
50 let before = content.len();
51 content.retain(|item| item.get("type").and_then(|kind| kind.as_str()) == Some("diff"));
56 changed |= content.len() != before;
57 for item in content.iter_mut() {
58 changed |= drop_diff_body(item);
59 }
60 changed
61}
62
63fn drop_diff_body(item: &mut serde_json::Value) -> bool {
64 use agent_client_protocol::schema::v1::ToolCallContent;
65
66 let mut content = match serde_json::from_value::<ToolCallContent>(item.clone()) {
70 Ok(content) => content,
71 Err(error) => {
73 tracing::warn!(%error, "skipping unreadable tool content during retention");
74 return false;
75 }
76 };
77 let ToolCallContent::Diff(diff) = &mut content else {
78 return false;
79 };
80 if !mj_core::diff::drop_patch_text(diff) {
81 return false;
82 }
83 match serde_json::to_value(&content) {
84 Ok(value) => {
85 *item = value;
86 true
87 }
88 Err(error) => {
89 tracing::warn!(%error, "could not rewrite a diff during retention");
90 false
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96enum ToolSummarySource {
97 Shell(String),
98 Argv {
99 executable: String,
100 arguments: Vec<String>,
101 },
102}
103
104pub fn tool_call_update_changes_presentation(
106 call: &ToolCall,
107 fields: &ToolCallUpdateFields,
108) -> bool {
109 fields
110 .title
111 .as_ref()
112 .is_some_and(|title| title != &call.title)
113 || fields.kind.is_some_and(|kind| kind != call.kind)
114 || fields
115 .raw_input
116 .as_ref()
117 .is_some_and(|input| Some(input) != call.raw_input.as_ref())
118 || (call.kind == ToolKind::Execute
119 && fields
120 .raw_output
121 .as_ref()
122 .is_some_and(|output| Some(output) != call.raw_output.as_ref())
123 && command_source(call.raw_input.as_ref()).is_none())
124}
125
126pub fn tool_call_presentation(call: &ToolCall) -> ToolCallPresentation {
128 let kind = call.kind;
129 if kind == ToolKind::Execute {
130 if let Some(source) = command_source(call.raw_input.as_ref()) {
131 return presentation_from_source(
132 source,
133 ToolSummarySourceKind::RawInput,
134 kind,
135 &call.title,
136 );
137 }
138 if let Some(source) = command_source(call.raw_output.as_ref()) {
139 return presentation_from_source(
140 source,
141 ToolSummarySourceKind::RawOutput,
142 kind,
143 &call.title,
144 );
145 }
146 }
147 presentation_from_title(&call.title, kind)
148}
149
150pub fn materialized_tool_call_presentation(
154 stored: Option<&ToolCallPresentation>,
155 call: &ToolCall,
156) -> ToolCallPresentation {
157 stored
158 .filter(|presentation| presentation.summary_version >= TOOL_SUMMARY_VERSION)
159 .cloned()
160 .unwrap_or_else(|| tool_call_presentation(call))
161}
162
163pub fn update_tool_call_presentation(
167 previous: Option<&ToolCallPresentation>,
168 title: &str,
169 kind: Option<ToolKind>,
170 raw_input: Option<&Value>,
171 raw_output: Option<&Value>,
172) -> ToolCallPresentation {
173 let next_kind = kind.unwrap_or_else(|| {
174 previous
175 .map(|presentation| presentation.tool_kind)
176 .unwrap_or_default()
177 });
178
179 if next_kind == ToolKind::Execute {
180 if let Some(source) = raw_input.and_then(|value| command_source(Some(value))) {
181 return presentation_from_source(
182 source,
183 ToolSummarySourceKind::RawInput,
184 next_kind,
185 title,
186 );
187 }
188 if let Some(source) = raw_output.and_then(|value| command_source(Some(value)))
189 && (raw_input.is_some()
190 || !previous.is_some_and(|previous| {
191 previous.source_kind == ToolSummarySourceKind::RawInput
192 }))
193 {
194 return presentation_from_source(
195 source,
196 ToolSummarySourceKind::RawOutput,
197 next_kind,
198 title,
199 );
200 }
201 if let Some(previous) = previous
202 && previous.tool_kind == ToolKind::Execute
203 && ((raw_input.is_none() && previous.source_kind == ToolSummarySourceKind::RawInput)
204 || (raw_input.is_none()
205 && raw_output.is_none()
206 && previous.source_kind == ToolSummarySourceKind::RawOutput))
207 {
208 return ToolCallPresentation {
209 tool_kind: next_kind,
210 ..previous.clone()
211 };
212 }
213 }
214
215 presentation_from_title(title, next_kind)
216}
217
218fn command_source(raw: Option<&Value>) -> Option<ToolSummarySource> {
219 let command = raw?.get("command")?;
220 match command {
221 Value::String(command) if !command.trim().is_empty() => {
222 Some(ToolSummarySource::Shell(command.clone()))
223 }
224 Value::Array(argv) => {
225 let argv = argv.iter().map(Value::as_str).collect::<Option<Vec<_>>>()?;
226 let first = argv.first()?.trim();
227 if first.is_empty() {
228 return None;
229 }
230 if is_shell_interpreter(first)
231 && let Some(script) = shell_script_argument(&argv[1..])
232 {
233 return Some(ToolSummarySource::Shell(script.to_owned()));
234 }
235 Some(ToolSummarySource::Argv {
236 executable: first.to_owned(),
237 arguments: argv[1..]
238 .iter()
239 .map(|argument| (*argument).to_owned())
240 .collect(),
241 })
242 }
243 _ => None,
244 }
245}
246
247fn is_shell_interpreter(value: &str) -> bool {
248 let executable = value.rsplit('/').next().unwrap_or(value);
249 matches!(executable, "sh" | "bash" | "dash" | "zsh")
250}
251
252fn shell_script_argument<'a>(arguments: &'a [&'a str]) -> Option<&'a str> {
253 let mut index = 0;
254 while index < arguments.len() {
255 let argument = arguments[index];
256 if argument == "--" {
257 return None;
258 }
259 if argument == "-c" || argument == "--command" {
260 return arguments.get(index + 1).copied();
261 }
262 if argument.starts_with('-') && !argument.starts_with("--") && argument[1..].contains('c') {
263 return arguments.get(index + 1).copied();
264 }
265 index += 1;
266 }
267 None
268}
269
270fn presentation_from_source(
271 source: ToolSummarySource,
272 source_kind: ToolSummarySourceKind,
273 tool_kind: ToolKind,
274 title: &str,
275) -> ToolCallPresentation {
276 let (source, summary) = match source {
277 ToolSummarySource::Shell(source) => {
278 let bounded = bound_summary_source(&source);
279 let summary = summarize_shell(&bounded)
280 .or_else(|| first_meaningful_token(title))
281 .unwrap_or_else(|| "tool".to_owned());
282 (bounded, summary)
283 }
284 ToolSummarySource::Argv {
285 executable,
286 arguments,
287 } => {
288 let source = std::iter::once(executable.as_str())
289 .chain(arguments.iter().map(String::as_str))
290 .collect::<Vec<_>>()
291 .join(" ");
292 let bounded = bound_summary_source(&source);
293 let summary = summarize_invocation(&executable, &arguments)
294 .or_else(|| first_meaningful_token(title))
295 .unwrap_or_else(|| "tool".to_owned());
296 (bounded, summary)
297 }
298 };
299 ToolCallPresentation {
300 summary,
301 source,
302 source_kind,
303 tool_kind,
304 summary_version: TOOL_SUMMARY_VERSION,
305 }
306}
307
308fn presentation_from_title(title: &str, tool_kind: ToolKind) -> ToolCallPresentation {
309 let source = title_source(title);
310 let bounded = bound_summary_source(&source);
311 let summary = if tool_kind == ToolKind::Execute {
312 summarize_shell(&bounded)
313 .or_else(|| first_meaningful_token(&bounded))
314 .unwrap_or_else(|| "tool".to_owned())
315 } else {
316 first_meaningful_token(&bounded).unwrap_or_else(|| "tool".to_owned())
317 };
318 ToolCallPresentation {
319 summary,
320 source: bounded,
321 source_kind: ToolSummarySourceKind::Title,
322 tool_kind,
323 summary_version: TOOL_SUMMARY_VERSION,
324 }
325}
326
327fn title_source(title: &str) -> String {
328 let title = title.trim();
329 let title = title
330 .strip_prefix("Running:")
331 .or_else(|| title.strip_prefix("Starting background:"))
332 .map(str::trim)
333 .unwrap_or(title);
334 if let Some(inner) = title
335 .strip_prefix("Execute `")
336 .and_then(|value| value.strip_suffix('`'))
337 {
338 return inner.to_owned();
339 }
340 title.to_owned()
341}
342
343fn first_meaningful_token(value: &str) -> Option<String> {
344 let token = value
345 .split_whitespace()
346 .next()?
347 .trim_matches(|character: char| {
348 !character.is_alphanumeric() && character != '/' && character != '.' && character != '_'
349 });
350 if token.is_empty() {
351 None
352 } else {
353 Some(token.trim_matches(['\'', '"', '`']).to_owned())
354 }
355}
356
357fn bound_summary_source(source: &str) -> String {
358 if source.len() <= TOOL_SUMMARY_SOURCE_BYTES {
359 return source.to_owned();
360 }
361 let mut end = TOOL_SUMMARY_SOURCE_BYTES;
362 while !source.is_char_boundary(end) {
363 end -= 1;
364 }
365 source[..end].to_owned()
366}
367
368fn summarize_shell(source: &str) -> Option<String> {
369 let mut parser = Parser::new();
370 parser
371 .set_language(&tree_sitter_bash::LANGUAGE.into())
372 .ok()?;
373 let tree = parser.parse(source, None)?;
374 let root = tree.root_node();
375 if root.has_error() {
376 return None;
377 }
378
379 let mut commands = Vec::new();
380 let mut operators = Vec::new();
381 let mut subshells = Vec::new();
382 if !collect_shell_tokens(root, source, &mut commands, &mut operators, &mut subshells) {
383 return None;
384 }
385 if commands.is_empty() {
386 return None;
387 }
388 commands.sort_by_key(|command| command.start);
389 operators.sort_by_key(|operator| operator.start);
390 subshells.sort_by_key(|subshell| subshell.start);
391
392 let mut tokens = Vec::new();
393 for (index, command) in commands.iter().enumerate() {
394 if index > 0 {
395 let previous = &commands[index - 1];
396 let separator = shell_separator_between(previous, command, &operators);
397 tokens.push(ShellToken {
398 start: separator.start,
399 text: separator.kind,
400 order: 1,
401 });
402 }
403 tokens.push(ShellToken {
404 start: command.start,
405 text: command.summary.clone(),
406 order: 2,
407 });
408 }
409
410 for subshell in subshells {
414 if !commands
415 .iter()
416 .any(|command| command.start >= subshell.start && command.end <= subshell.end)
417 {
418 continue;
419 }
420 let close = subshell.end.saturating_sub(1);
421 tokens.push(ShellToken {
422 start: subshell.start,
423 text: "(".to_owned(),
424 order: 0,
425 });
426 tokens.push(ShellToken {
427 start: close,
428 text: ")".to_owned(),
429 order: 3,
430 });
431 }
432
433 tokens.sort_by_key(|token| (token.start, token.order));
434 Some(join_shell_tokens(
435 tokens.into_iter().map(|token| token.text).collect(),
436 ))
437}
438
439#[derive(Debug, Clone)]
440struct ShellCommandToken {
441 start: usize,
442 end: usize,
443 summary: String,
444}
445
446#[derive(Debug, Clone)]
447struct ShellOperatorToken {
448 start: usize,
449 kind: String,
450}
451
452#[derive(Debug, Clone)]
453struct ShellSubshell {
454 start: usize,
455 end: usize,
456}
457
458#[derive(Debug, Clone)]
459struct ShellToken {
460 start: usize,
461 text: String,
462 order: u8,
463}
464
465fn collect_shell_tokens(
466 node: Node<'_>,
467 source: &str,
468 commands: &mut Vec<ShellCommandToken>,
469 operators: &mut Vec<ShellOperatorToken>,
470 subshells: &mut Vec<ShellSubshell>,
471) -> bool {
472 let kind = node.kind();
473 if matches!(kind, "command_substitution" | "process_substitution") {
474 return true;
475 }
476 if kind == "command" {
477 if let Some(summary) = summarize_command_node(node, source) {
478 commands.push(ShellCommandToken {
479 start: node.start_byte(),
480 end: node.end_byte(),
481 summary,
482 });
483 return true;
484 }
485 return false;
486 }
487 if is_shell_operator(node) {
488 operators.push(ShellOperatorToken {
489 start: node.start_byte(),
490 kind: kind.to_owned(),
491 });
492 return true;
493 }
494
495 if kind == "subshell" {
496 subshells.push(ShellSubshell {
497 start: node.start_byte(),
498 end: node.end_byte(),
499 });
500 }
501
502 let mut cursor = node.walk();
503 node.children(&mut cursor)
504 .all(|child| collect_shell_tokens(child, source, commands, operators, subshells))
505}
506
507fn shell_separator_between(
508 previous: &ShellCommandToken,
509 next: &ShellCommandToken,
510 operators: &[ShellOperatorToken],
511) -> ShellOperatorToken {
512 let mut candidates = operators
513 .iter()
514 .filter(|operator| operator.start >= previous.end && operator.start < next.start);
515 let structural = candidates.clone().find(|operator| operator.kind != ";");
516 if let Some(operator) = structural {
517 return operator.clone();
518 }
519 if let Some(operator) = candidates.find(|operator| operator.kind == ";") {
520 return operator.clone();
521 }
522 ShellOperatorToken {
523 start: previous.end,
524 kind: ";".to_owned(),
525 }
526}
527
528#[derive(Debug, Clone)]
529struct InvocationArgument {
530 value: String,
531 literal: bool,
532}
533
534fn summarize_command_node(node: Node<'_>, source: &str) -> Option<String> {
535 let name = node.child_by_field_name("name")?;
536 let executable = shell_command_name(name, source)?;
537 let mut cursor = node.walk();
538 let arguments = node
539 .children_by_field_name("argument", &mut cursor)
540 .map(|argument| {
541 let value = shell_argument_value(argument, source);
542 InvocationArgument {
543 value: value.clone().unwrap_or_default(),
544 literal: value.is_some(),
545 }
546 })
547 .collect::<Vec<_>>();
548 summarize_invocation_with_literals(&executable, &arguments)
549}
550
551fn shell_command_name(node: Node<'_>, source: &str) -> Option<String> {
552 if contains_dynamic_shell_node(node) {
553 return None;
554 }
555 normalize_command_name(&source[node.byte_range()])
556}
557
558fn shell_argument_value(node: Node<'_>, source: &str) -> Option<String> {
559 if contains_dynamic_shell_node(node) {
560 return None;
561 }
562 let text = source[node.byte_range()].trim();
563 if text.is_empty() {
564 return None;
565 }
566 Some(strip_matching_quotes(text).to_owned())
567}
568
569fn contains_dynamic_shell_node(node: Node<'_>) -> bool {
570 if matches!(
571 node.kind(),
572 "expansion"
573 | "simple_expansion"
574 | "command_substitution"
575 | "process_substitution"
576 | "arithmetic_expansion"
577 ) {
578 return true;
579 }
580 let mut cursor = node.walk();
581 node.children(&mut cursor).any(contains_dynamic_shell_node)
582}
583
584fn summarize_invocation(executable: &str, arguments: &[String]) -> Option<String> {
585 summarize_invocation_with_literals(
586 executable,
587 &arguments
588 .iter()
589 .map(|value| InvocationArgument {
590 value: strip_matching_quotes(value).to_owned(),
591 literal: true,
592 })
593 .collect::<Vec<_>>(),
594 )
595}
596
597fn summarize_invocation_with_literals(
598 executable: &str,
599 arguments: &[InvocationArgument],
600) -> Option<String> {
601 let executable = normalize_command_name(executable)?;
602 let basename = executable
603 .rsplit(['/', '\\'])
604 .next()
605 .unwrap_or(&executable)
606 .to_owned();
607 let mut words = vec![executable];
608 if !is_summary_executable(&basename) {
609 return Some(words.remove(0));
610 }
611 let mut index = 0;
612
613 if basename == "cargo"
614 && arguments.first().is_some_and(|argument| {
615 argument.literal && argument.value.starts_with('+') && argument.value.len() > 1
616 })
617 {
618 index += 1;
619 }
620
621 let first_verb = loop {
622 let Some(argument) = arguments.get(index) else {
623 return Some(words.remove(0));
624 };
625 if !argument.literal {
626 return Some(words.remove(0));
627 }
628 if argument.value == "--" || argument.value.starts_with('-') {
629 if let Some(consumed) = known_leading_option_arguments(&basename, arguments, index) {
630 index += consumed;
631 continue;
632 }
633 return Some(words.remove(0));
634 }
635 break argument.value.clone();
636 };
637 words.push(first_verb.clone());
638
639 if allows_second_verb(&basename, &first_verb) {
640 let first = index + 1;
641 if let Some(argument) = arguments.get(first)
642 && argument.literal
643 && !argument.value.starts_with('-')
644 && argument.value != "--"
645 {
646 words.push(argument.value.clone());
647 }
648 }
649 Some(words.join(" "))
650}
651
652fn is_summary_executable(basename: &str) -> bool {
653 matches!(
654 basename,
655 "git"
656 | "gh"
657 | "cargo"
658 | "rustup"
659 | "npm"
660 | "pnpm"
661 | "yarn"
662 | "bun"
663 | "uv"
664 | "pip"
665 | "pip3"
666 | "docker"
667 | "podman"
668 | "nice"
669 )
670}
671
672fn allows_second_verb(basename: &str, first_verb: &str) -> bool {
673 match basename {
674 "gh" => matches!(
675 first_verb,
676 "alias"
677 | "auth"
678 | "cache"
679 | "codespace"
680 | "config"
681 | "extension"
682 | "gist"
683 | "gpg-key"
684 | "issue"
685 | "label"
686 | "org"
687 | "pr"
688 | "project"
689 | "release"
690 | "repo"
691 | "ruleset"
692 | "run"
693 | "search"
694 | "secret"
695 | "ssh-key"
696 | "variable"
697 | "workflow"
698 ),
699 "docker" => matches!(
700 first_verb,
701 "buildx"
702 | "compose"
703 | "config"
704 | "context"
705 | "container"
706 | "image"
707 | "manifest"
708 | "network"
709 | "node"
710 | "plugin"
711 | "secret"
712 | "service"
713 | "stack"
714 | "swarm"
715 | "system"
716 | "trust"
717 | "volume"
718 ),
719 "podman" => matches!(
720 first_verb,
721 "artifact"
722 | "container"
723 | "farm"
724 | "generate"
725 | "image"
726 | "machine"
727 | "manifest"
728 | "network"
729 | "play"
730 | "pod"
731 | "secret"
732 | "system"
733 | "volume"
734 ),
735 "uv" => matches!(first_verb, "cache" | "pip" | "python" | "tool"),
736 "rustup" => matches!(
737 first_verb,
738 "component" | "override" | "target" | "toolchain"
739 ),
740 _ => false,
741 }
742}
743
744fn known_leading_option_arguments(
745 basename: &str,
746 arguments: &[InvocationArgument],
747 index: usize,
748) -> Option<usize> {
749 let option = arguments.get(index)?.value.as_str();
750 if option == "--" {
751 if basename == "nice" {
752 return Some(1);
753 }
754 return None;
755 }
756 let (option_name, attached_value) = option
757 .split_once('=')
758 .map_or((option, false), |(name, _)| (name, true));
759 if basename == "nice"
760 && option.starts_with('-')
761 && option.len() > 1
762 && option[1..].parse::<i32>().is_ok()
763 {
764 return Some(1);
765 }
766 if basename == "nice"
767 && option
768 .strip_prefix("-n")
769 .is_some_and(|value| !value.is_empty() && value.parse::<i32>().is_ok())
770 {
771 return Some(1);
772 }
773 let attached_short_value = match basename {
774 "git" => option.starts_with("-C") || option.starts_with("-c"),
775 "gh" => option.starts_with("-R"),
776 "docker" | "podman" => option.starts_with("-H"),
777 _ => false,
778 } && option.len() > 2;
779 let takes_value = match basename {
780 "git" => matches!(
781 option_name,
782 "-C" | "-c"
783 | "--config-env"
784 | "--exec-path"
785 | "--git-dir"
786 | "--namespace"
787 | "--super-prefix"
788 | "--work-tree"
789 ),
790 "gh" => matches!(
791 option_name,
792 "-R" | "--hostname" | "--repo" | "--jq" | "--template"
793 ),
794 "cargo" => matches!(
795 option_name,
796 "--manifest-path" | "--target-dir" | "--config" | "--color"
797 ),
798 "npm" | "pnpm" | "yarn" | "bun" => {
799 matches!(
800 option_name,
801 "--cwd" | "--dir" | "--prefix" | "--registry" | "--userconfig"
802 )
803 }
804 "uv" => matches!(option_name, "--directory" | "--project" | "--python"),
805 "rustup" => matches!(option_name, "--toolchain"),
806 "nice" => {
807 matches!(option_name, "-n" | "--adjustment")
808 }
809 "docker" | "podman" => matches!(
810 option_name,
811 "-H" | "--config" | "--connection" | "--context" | "--host" | "--log-level"
812 ),
813 _ => false,
814 };
815 if attached_short_value {
816 return Some(1);
817 }
818 if attached_value {
819 return takes_value.then_some(1);
820 }
821 if takes_value {
822 return arguments
823 .get(index + 1)
824 .filter(|argument| argument.literal)
825 .map(|_| 2);
826 }
827 let known_flag = match basename {
828 "git" => matches!(
829 option_name,
830 "-p" | "--paginate"
831 | "-P"
832 | "--no-pager"
833 | "--bare"
834 | "--literal-pathspecs"
835 | "--glob-pathspecs"
836 | "--noglob-pathspecs"
837 | "--icase-pathspecs"
838 | "--no-optional-locks"
839 | "--no-advice"
840 ),
841 "gh" => false,
842 "cargo" => matches!(
843 option_name,
844 "-q" | "--quiet" | "-v" | "--verbose" | "--locked" | "--offline" | "--frozen"
845 ),
846 "npm" | "pnpm" | "yarn" | "bun" => {
847 matches!(option_name, "-g" | "--global" | "--silent")
848 }
849 "uv" => matches!(
850 option_name,
851 "-q" | "--quiet" | "-v" | "--verbose" | "--offline"
852 ),
853 "rustup" => matches!(option_name, "-q" | "--quiet" | "-v" | "--verbose"),
854 "docker" | "podman" => matches!(option_name, "-D" | "--debug" | "--tls"),
855 "nice" => false,
856 _ => false,
857 };
858 known_flag.then_some(1)
859}
860
861fn strip_matching_quotes(value: &str) -> &str {
862 value
863 .strip_prefix('"')
864 .and_then(|value| value.strip_suffix('"'))
865 .or_else(|| {
866 value
867 .strip_prefix('\'')
868 .and_then(|value| value.strip_suffix('\''))
869 })
870 .unwrap_or(value)
871}
872
873fn is_shell_operator(node: Node<'_>) -> bool {
874 match node.kind() {
875 ";" => true,
876 "&&" | "||" => node.parent().is_some_and(|parent| parent.kind() == "list"),
877 "|" | "|&" => node
878 .parent()
879 .is_some_and(|parent| parent.kind() == "pipeline"),
880 "&" => node.parent().is_none_or(|parent| {
881 !matches!(
882 parent.kind(),
883 "binary_expression" | "unary_expression" | "postfix_expression"
884 )
885 }),
886 _ => false,
887 }
888}
889
890fn normalize_command_name(text: &str) -> Option<String> {
891 let text = text.trim();
892 if text.contains('$') || text.contains('`') {
893 return None;
894 }
895 let text = text
896 .strip_prefix('"')
897 .and_then(|value| value.strip_suffix('"'))
898 .or_else(|| {
899 text.strip_prefix('\'')
900 .and_then(|value| value.strip_suffix('\''))
901 })
902 .unwrap_or(text);
903 (!text.is_empty()).then(|| text.to_owned())
904}
905
906fn join_shell_tokens(tokens: Vec<String>) -> String {
907 let mut output = String::new();
908 for token in tokens {
909 match token.as_str() {
910 "(" => {
911 if !output.is_empty() && !output.ends_with(' ') {
912 output.push(' ');
913 }
914 output.push('(');
915 }
916 ")" => {
917 output = output.trim_end().to_owned();
918 output.push(')');
919 }
920 _ => {
921 if !output.is_empty() && !output.ends_with(' ') && !output.ends_with('(') {
922 output.push(' ');
923 }
924 output.push_str(&token);
925 }
926 }
927 }
928 output
929}
930
931pub fn tool_content_details(
932 content: &[ToolCallContent],
933 terminal_outputs: &[TerminalOutputRecord],
934 raw_output: Option<&serde_json::Value>,
935) -> Vec<String> {
936 let mut details = Vec::new();
937 let mut referenced: Vec<&str> = Vec::new();
938 for item in content {
939 let detail = match item {
940 ToolCallContent::Content(content) => content_block_text(&content.content),
941 ToolCallContent::Diff(_) => None,
942 ToolCallContent::Terminal(terminal) => {
946 let terminal_id = terminal.terminal_id.0.as_ref();
947 referenced.push(terminal_id);
948 Some(
949 terminal_outputs
950 .iter()
951 .find(|record| record.terminal_id.as_str() == terminal_id)
952 .map(terminal_output_detail)
953 .or_else(|| raw_output.and_then(raw_output_terminal_detail))
954 .unwrap_or_else(|| format!("terminal {}", terminal.terminal_id)),
955 )
956 }
957 _ => None,
958 };
959 if let Some(detail) = detail {
960 details.push(sanitize_terminal_text(&detail));
961 }
962 }
963 for record in terminal_outputs {
967 if referenced.contains(&record.terminal_id.as_str()) {
968 continue;
969 }
970 let output = sanitize_terminal_text(&record.output);
971 if !output.is_empty() && details.iter().any(|detail| detail == &output) {
972 details.push(terminal_exit_summary(record));
976 } else {
977 details.push(sanitize_terminal_text(&terminal_output_detail(record)));
978 }
979 }
980 details
981}
982
983fn raw_output_terminal_detail(raw_output: &serde_json::Value) -> Option<String> {
988 let output = raw_output.get("formatted_output")?.as_str()?;
989 let Some(exit_code) = raw_output
990 .get("exit_code")
991 .and_then(serde_json::Value::as_i64)
992 else {
993 return Some(output.to_owned());
994 };
995 let summary = format!("exited {exit_code}");
996 if output.is_empty() {
997 return Some(summary);
998 }
999 Some(format!("{output}\n{summary}"))
1000}
1001
1002pub fn terminal_output_detail(record: &TerminalOutputRecord) -> String {
1004 let summary = terminal_exit_summary(record);
1005 if record.output.is_empty() {
1006 return summary;
1007 }
1008 format!("{}\n{summary}", record.output)
1009}
1010
1011fn terminal_exit_summary(record: &TerminalOutputRecord) -> String {
1013 let mut summary = match (record.exit_code, &record.signal) {
1014 (_, Some(signal)) => format!("killed by {signal}"),
1015 (Some(code), None) => format!("exited {code}"),
1016 (None, None) => "released before exit".to_owned(),
1017 };
1018 if record.truncated {
1019 summary.push_str(" ยท output truncated");
1020 }
1021 summary
1022}
1023
1024pub fn tool_diff_paths(content: &[ToolCallContent]) -> Vec<String> {
1025 content
1026 .iter()
1027 .filter_map(|item| match item {
1028 ToolCallContent::Diff(diff) => Some(diff.path.display().to_string()),
1029 _ => None,
1030 })
1031 .collect()
1032}
1033
1034pub fn tool_location_details(locations: &[ToolCallLocation]) -> Vec<String> {
1035 locations
1036 .iter()
1037 .map(|location| match location.line {
1038 Some(line) => format!("{}:{line}", location.path.display()),
1039 None => location.path.display().to_string(),
1040 })
1041 .collect()
1042}
1043
1044pub(crate) fn push_streamed_entry(
1048 entries: &mut Vec<ChatEntry>,
1049 seq: u64,
1050 recorded_at_ms: Option<i64>,
1051 role: ChatRole,
1052 message_id: Option<String>,
1053 text: &str,
1054) {
1055 let text = sanitize_terminal_text(text);
1056 if let Some(last) = entries.last_mut()
1057 && last.role == role
1058 && (role == ChatRole::Thought || last.message_id == message_id)
1059 {
1060 last.touch(seq);
1061 if role == ChatRole::Thought
1062 && last.message_id != message_id
1063 && !last.text.is_empty()
1064 && !text.is_empty()
1065 {
1066 while last.text.ends_with('\n') {
1067 last.text.pop();
1068 }
1069 last.text.push('\n');
1070 last.text.push_str(text.trim_start_matches('\n'));
1071 } else {
1072 last.text.push_str(&text);
1073 }
1074 return;
1075 }
1076 let mut entry = ChatEntry::plain(seq, role, text).with_recorded_at(recorded_at_ms);
1077 entry.message_id = message_id;
1078 entries.push(entry);
1079}
1080
1081pub fn apply_session_update_to_entries(
1085 entries: &mut Vec<ChatEntry>,
1086 seq: u64,
1087 recorded_at_ms: Option<i64>,
1088 update: SessionUpdate,
1089) -> Option<SessionUpdate> {
1090 match update {
1091 SessionUpdate::AgentMessageChunk(chunk) => {
1092 let message_id = chunk.message_id.map(|id| id.to_string());
1093 if let Some(text) = content_block_text(&chunk.content) {
1094 push_streamed_entry(
1095 entries,
1096 seq,
1097 recorded_at_ms,
1098 ChatRole::Agent,
1099 message_id,
1100 &text,
1101 );
1102 }
1103 }
1104 SessionUpdate::AgentThoughtChunk(chunk) => {
1105 let message_id = chunk.message_id.map(|id| id.to_string());
1106 if let Some(text) = content_block_text(&chunk.content) {
1107 push_streamed_entry(
1108 entries,
1109 seq,
1110 recorded_at_ms,
1111 ChatRole::Thought,
1112 message_id,
1113 &text,
1114 );
1115 }
1116 }
1117 SessionUpdate::UserMessageChunk(_) => {}
1120 SessionUpdate::ToolCall(call) => {
1121 let presentation = tool_call_presentation(&call);
1122 let mut entry = ChatEntry::tool(
1123 seq,
1124 call.title,
1125 Some(call.tool_call_id.to_string()),
1126 tool_status(&call.status),
1127 );
1128 entry.tool_summary = Some(presentation.summary.clone());
1129 entry.tool_presentation = Some(presentation);
1130 entry.tool_content = tool_content_details(&call.content, &[], call.raw_output.as_ref());
1131 entry.tool_diffstats = tool_diff_paths(&call.content);
1132 entry.tool_locations = tool_location_details(&call.locations);
1133 entries.push(entry);
1134 }
1135 SessionUpdate::ToolCallUpdate(update) => {
1136 let tool_call_id = update.tool_call_id.to_string();
1137 let entry = entries.iter_mut().rev().find(|entry| {
1138 entry.role == ChatRole::Tool
1139 && entry.tool_call_id.as_deref() == Some(tool_call_id.as_str())
1140 })?;
1141 entry.touch(seq);
1142 let kind = update.fields.kind;
1143 let raw_input = update.fields.raw_input.clone();
1144 let raw_output = update.fields.raw_output.clone();
1145 if let Some(title) = update.fields.title {
1146 entry.text = sanitize_terminal_text(&title);
1147 }
1148 if let Some(status) = update.fields.status {
1149 entry.tool_status = Some(tool_status(&status));
1150 }
1151 if let Some(content) = update.fields.content {
1152 entry.tool_content =
1153 tool_content_details(&content, &[], update.fields.raw_output.as_ref());
1154 entry.tool_diffstats = tool_diff_paths(&content);
1155 }
1156 if let Some(locations) = update.fields.locations {
1157 entry.tool_locations = tool_location_details(&locations);
1158 }
1159 let presentation = update_tool_call_presentation(
1160 entry.tool_presentation.as_ref(),
1161 &entry.text,
1162 kind,
1163 raw_input.as_ref(),
1164 raw_output.as_ref(),
1165 );
1166 entry.tool_summary = Some(presentation.summary.clone());
1167 entry.tool_presentation = Some(presentation);
1168 }
1169 SessionUpdate::Plan(plan) => {
1170 let lines = plan
1171 .entries
1172 .into_iter()
1173 .map(|entry| PlanLine {
1174 text: sanitize_terminal_text(&entry.content),
1175 status: plan_status(&entry.status),
1176 })
1177 .collect();
1178 let latest_user_seq = entries
1179 .iter()
1180 .rev()
1181 .find(|entry| entry.role == ChatRole::User)
1182 .map_or(0, |entry| entry.seq);
1183 if let Some(entry) = entries
1184 .iter_mut()
1185 .rev()
1186 .find(|entry| entry.role == ChatRole::Plan && entry.seq > latest_user_seq)
1187 {
1188 entry.touch(seq);
1189 entry.plan = lines;
1190 } else {
1191 entries.push(ChatEntry::plan(seq, lines));
1192 }
1193 }
1194 other => return Some(other),
1195 }
1196 None
1197}
1198
1199pub fn apply_runtime_event_to_entries(
1203 entries: &mut Vec<ChatEntry>,
1204 seq: u64,
1205 recorded_at_ms: Option<i64>,
1206 runtime: RuntimeEvent,
1207) -> Option<RuntimeEvent> {
1208 match runtime {
1209 RuntimeEvent::SessionUpdate { update } => {
1210 let parsed = match serde_json::from_value::<SessionUpdate>(update.clone()) {
1211 Ok(parsed) => parsed,
1212 Err(error) => {
1213 tracing::debug!(%error, "ignoring invalid ACP session update");
1214 return None;
1215 }
1216 };
1217 apply_session_update_to_entries(entries, seq, recorded_at_ms, parsed)
1218 .map(|_| RuntimeEvent::SessionUpdate { update })
1219 }
1220 RuntimeEvent::Warning { message } => {
1221 entries.push(ChatEntry::plain(
1222 seq,
1223 ChatRole::System,
1224 format!("warning: {message}"),
1225 ));
1226 None
1227 }
1228 RuntimeEvent::ConfigApplied { key, value, .. } => {
1229 entries.push(ChatEntry::plain(
1230 seq,
1231 ChatRole::System,
1232 format!("{key} set to {value}"),
1233 ));
1234 None
1235 }
1236 RuntimeEvent::SessionStarted { resumed: false, .. } => {
1237 entries.push(ChatEntry::plain(
1238 seq,
1239 ChatRole::System,
1240 "harness session started",
1241 ));
1242 None
1243 }
1244 RuntimeEvent::SessionStarted { resumed: true, .. } => None,
1245 other => Some(other),
1246 }
1247}
1248
1249pub fn transcript_item_text(item: &TranscriptItem) -> String {
1255 match &item.body {
1256 TranscriptBody::User { content } => materialized_content_text(content),
1257 TranscriptBody::Agent { chunks, .. } | TranscriptBody::Thought { chunks, .. } => {
1258 materialized_chunks_text(chunks)
1259 }
1260 TranscriptBody::Tool {
1261 call,
1262 terminal_outputs,
1263 presentation,
1264 ..
1265 } => {
1266 let Ok(call) = ToolCall::deserialize(call) else {
1267 return "[invalid tool call]".to_owned();
1268 };
1269 let mut text = materialized_tool_call_presentation(presentation.as_deref(), &call)
1270 .summary
1271 .clone();
1272 if text.trim().is_empty() {
1273 text = call.title.clone();
1274 }
1275 for record in terminal_outputs {
1276 text.push('\n');
1277 text.push_str(&terminal_output_detail(record));
1278 }
1279 sanitize_terminal_text(&text)
1280 }
1281 TranscriptBody::TerminalOutput { record } => {
1282 sanitize_terminal_text(&terminal_output_detail(record))
1283 }
1284 TranscriptBody::Plan { plan } => {
1285 let Ok(plan) = agent_client_protocol::schema::v1::Plan::deserialize(plan) else {
1286 return String::new();
1287 };
1288 plan.entries
1289 .iter()
1290 .map(|entry| {
1291 let status = match plan_status(&entry.status) {
1292 PlanStatus::Pending => "pending",
1293 PlanStatus::Running => "running",
1294 PlanStatus::Completed => "completed",
1295 };
1296 format!("[{status}] {}", sanitize_terminal_text(&entry.content))
1297 })
1298 .collect::<Vec<_>>()
1299 .join("\n")
1300 }
1301 TranscriptBody::PlanProposal { plan, .. } => plan.clone(),
1302 TranscriptBody::System { text } => text.clone(),
1303 }
1304}
1305
1306pub(crate) fn compute_tool_diffstats(content: &[ToolCallContent]) -> Vec<String> {
1307 content
1308 .iter()
1309 .filter_map(|item| match item {
1310 ToolCallContent::Diff(diff) => Some(format_diffstat(diff)),
1311 _ => None,
1312 })
1313 .collect()
1314}
1315
1316pub fn materialized_tool_diffstats(item: &TranscriptItem) -> Option<Vec<String>> {
1317 let TranscriptBody::Tool { call, .. } = &item.body else {
1318 return None;
1319 };
1320 let call = match ToolCall::deserialize(call) {
1321 Ok(call) => call,
1322 Err(error) => {
1323 tracing::warn!(
1324 stable_id = %item.stable_id,
1325 %error,
1326 "could not decode a stored tool call while reading diff summary"
1327 );
1328 return None;
1329 }
1330 };
1331 if !matches!(
1332 tool_status(&call.status),
1333 ToolStatus::Completed | ToolStatus::Failed
1334 ) {
1335 return None;
1336 }
1337 let diffstats = compute_tool_diffstats(&call.content);
1338 (!diffstats.is_empty()).then_some(diffstats)
1339}
1340
1341fn format_diffstat(diff: &agent_client_protocol::schema::v1::Diff) -> String {
1342 let patch = mj_core::diff::patch_of(diff);
1346 format!(
1347 "{} +{} โ{}",
1348 diff.path.display(),
1349 patch.insertions,
1350 patch.deletions
1351 )
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use super::*;
1357 use agent_client_protocol::schema::v1::{ToolCall, ToolCallStatus};
1358 use serde_json::json;
1359
1360 #[test]
1361 fn acp_new_file_diff_counts_each_inserted_line() {
1362 let diff = agent_client_protocol::schema::v1::Diff::new("/workspace/new.txt", "one\ntwo\n");
1363
1364 assert_eq!(format_diffstat(&diff), "/workspace/new.txt +2 \u{2212}0");
1365 }
1366
1367 #[test]
1368 fn terminal_exit_summary_names_signal_release_and_truncation() {
1369 let record = |exit_code, signal: Option<&str>, truncated| TerminalOutputRecord {
1370 terminal_id: "term-1".into(),
1371 output: "out".into(),
1372 truncated,
1373 exit_code,
1374 signal: signal.map(str::to_owned),
1375 };
1376
1377 assert_eq!(
1378 terminal_exit_summary(&record(Some(0), None, false)),
1379 "exited 0"
1380 );
1381 assert_eq!(
1382 terminal_exit_summary(&record(Some(1), None, true)),
1383 "exited 1 ยท output truncated"
1384 );
1385 assert_eq!(
1386 terminal_exit_summary(&record(None, Some("SIGKILL"), false)),
1387 "killed by SIGKILL"
1388 );
1389 assert_eq!(
1390 terminal_exit_summary(&record(None, None, false)),
1391 "released before exit"
1392 );
1393
1394 let mut silent = record(None, Some("SIGTERM"), false);
1397 silent.output.clear();
1398 assert_eq!(terminal_output_detail(&silent), "killed by SIGTERM");
1399 }
1400
1401 #[test]
1402 fn execute_shell_summary_keeps_commands_and_control_operators() {
1403 let call = ToolCall::new("call-1", "Bash")
1404 .kind(ToolKind::Execute)
1405 .raw_input(json!({
1406 "command": "cd dir && python x.py | cat | wc ; print ok"
1407 }));
1408
1409 let presentation = tool_call_presentation(&call);
1410 assert_eq!(presentation.summary, "cd && python | cat | wc ; print");
1411 assert_eq!(presentation.source_kind, ToolSummarySourceKind::RawInput);
1412 }
1413
1414 #[test]
1415 fn execute_sources_handle_shell_argv_and_ordinary_argv() {
1416 let shell = ToolCall::new("shell", "Terminal")
1417 .kind(ToolKind::Execute)
1418 .raw_input(json!({
1419 "command": ["bash", "-lc", "cd dir && python x.py | cat"]
1420 }));
1421 assert_eq!(tool_call_presentation(&shell).summary, "cd && python | cat");
1422
1423 let argv = ToolCall::new("argv", "Execute")
1424 .kind(ToolKind::Execute)
1425 .raw_input(json!({"command": ["python", "-c", "print(1)"]}));
1426 assert_eq!(tool_call_presentation(&argv).summary, "python");
1427 }
1428
1429 #[test]
1430 fn output_updates_reuse_input_command_summaries_but_changed_commands_do_not() {
1431 let call = ToolCall::new("shell", "Bash")
1432 .kind(ToolKind::Execute)
1433 .raw_input(json!({"command": "cargo test"}));
1434 let mut output = ToolCallUpdateFields::default();
1435 output.raw_output = Some(json!({"output": "x".repeat(128 * 1024)}));
1436 assert!(!tool_call_update_changes_presentation(&call, &output));
1437 let mut status = ToolCallUpdateFields::default();
1438 status.status = Some(ToolCallStatus::Completed);
1439 assert!(!tool_call_update_changes_presentation(&call, &status));
1440 let mut changed = ToolCallUpdateFields::default();
1441 changed.raw_input = Some(json!({"command": "cargo check"}));
1442 assert!(tool_call_update_changes_presentation(&call, &changed));
1443 let output_call = ToolCall::new("output", "Bash").kind(ToolKind::Execute);
1444 assert!(tool_call_update_changes_presentation(&output_call, &output));
1445 }
1446
1447 fn execute_summary(command: serde_json::Value) -> String {
1448 let call = ToolCall::new("argv", "Bash")
1449 .kind(ToolKind::Execute)
1450 .raw_input(command);
1451 tool_call_presentation(&call).summary
1452 }
1453
1454 #[test]
1455 fn argv_summary_keeps_registered_command_verbs() {
1456 assert_eq!(
1457 execute_summary(json!({
1458 "command": ["git", "--no-pager", "status", "--short"]
1459 })),
1460 "git status"
1461 );
1462 assert_eq!(
1463 execute_summary(json!({
1464 "command": ["cargo", "+nightly", "test", "--package", "hel"]
1465 })),
1466 "cargo test"
1467 );
1468 assert_eq!(
1469 execute_summary(json!({
1470 "command": ["gh", "--hostname", "github.example", "pr", "list"]
1471 })),
1472 "gh pr list"
1473 );
1474 assert_eq!(
1475 execute_summary(json!({
1476 "command": ["docker", "--context", "work", "compose", "up"]
1477 })),
1478 "docker compose up"
1479 );
1480 assert_eq!(
1481 execute_summary(json!({
1482 "command": ["podman", "machine", "list"]
1483 })),
1484 "podman machine list"
1485 );
1486 assert_eq!(
1487 execute_summary(json!({
1488 "command": ["uv", "--project", "app", "pip", "install", "ruff"]
1489 })),
1490 "uv pip install"
1491 );
1492 assert_eq!(
1493 execute_summary(json!({
1494 "command": ["rustup", "toolchain", "list"]
1495 })),
1496 "rustup toolchain list"
1497 );
1498 assert_eq!(
1499 execute_summary(json!({
1500 "command": ["npm", "--prefix", "web", "run", "build"]
1501 })),
1502 "npm run"
1503 );
1504 }
1505
1506 #[test]
1507 fn string_shell_and_argv_summaries_have_the_same_invocation_depth() {
1508 let string = ToolCall::new("string", "Bash")
1509 .kind(ToolKind::Execute)
1510 .raw_input(json!({"command": "git --no-pager status --short"}));
1511 let argv = ToolCall::new("argv", "Bash")
1512 .kind(ToolKind::Execute)
1513 .raw_input(json!({"command": ["git", "--no-pager", "status", "--short"]}));
1514 assert_eq!(
1515 tool_call_presentation(&string).summary,
1516 tool_call_presentation(&argv).summary
1517 );
1518 }
1519
1520 #[test]
1521 fn unknown_leading_options_make_verb_position_ambiguous() {
1522 assert_eq!(
1523 execute_summary(json!({"command": ["git", "--mystery", "status"]})),
1524 "git"
1525 );
1526 assert_eq!(
1527 execute_summary(json!({"command": ["cargo", "--mystery", "test"]})),
1528 "cargo"
1529 );
1530 }
1531
1532 #[test]
1533 fn non_whitelisted_commands_keep_only_the_executable() {
1534 assert_eq!(
1535 execute_summary(json!({"command": ["mytool", "build", "src"]})),
1536 "mytool"
1537 );
1538 assert_eq!(
1539 execute_summary(json!({"command": ["mytool", "./script.sh"]})),
1540 "mytool"
1541 );
1542 assert_eq!(
1543 execute_summary(json!({"command": ["python", "script.py"]})),
1544 "python"
1545 );
1546 }
1547
1548 #[test]
1549 fn quoted_and_dynamic_shell_verbs_are_distinguished() {
1550 let quoted = ToolCall::new("quoted", "Bash")
1551 .kind(ToolKind::Execute)
1552 .raw_input(json!({"command": "git \"status\""}));
1553 assert_eq!(tool_call_presentation("ed).summary, "git status");
1554
1555 let dynamic = ToolCall::new("dynamic", "Bash")
1556 .kind(ToolKind::Execute)
1557 .raw_input(json!({"command": "git \"$verb\""}));
1558 assert_eq!(tool_call_presentation(&dynamic).summary, "git");
1559
1560 let dynamic_name = ToolCall::new("dynamic-name", "Bash")
1561 .kind(ToolKind::Execute)
1562 .raw_input(json!({"command": "$command status"}));
1563 assert_eq!(tool_call_presentation(&dynamic_name).summary, "Bash");
1564 }
1565
1566 #[test]
1567 fn wrappers_remain_direct_invocations() {
1568 assert_eq!(
1569 tool_call_presentation(
1570 &ToolCall::new("sudo", "Bash")
1571 .kind(ToolKind::Execute)
1572 .raw_input(json!({"command": "sudo -n git status"}))
1573 )
1574 .summary,
1575 "sudo"
1576 );
1577 assert_eq!(
1578 tool_call_presentation(
1579 &ToolCall::new("env", "Bash")
1580 .kind(ToolKind::Execute)
1581 .raw_input(json!({"command": "env FOO=bar git status"}))
1582 )
1583 .summary,
1584 "env"
1585 );
1586 assert_eq!(
1587 tool_call_presentation(
1588 &ToolCall::new("command", "Bash")
1589 .kind(ToolKind::Execute)
1590 .raw_input(json!({"command": "command git status"}))
1591 )
1592 .summary,
1593 "command"
1594 );
1595 }
1596
1597 #[test]
1598 fn second_verbs_require_a_registered_namespace() {
1599 assert_eq!(
1600 execute_summary(json!({"command": ["gh", "api", "graphql"]})),
1601 "gh api"
1602 );
1603 assert_eq!(
1604 execute_summary(json!({"command": ["docker", "run", "ubuntu"]})),
1605 "docker run"
1606 );
1607 assert_eq!(
1608 execute_summary(json!({"command": ["git", "future-verb"]})),
1609 "git future-verb"
1610 );
1611 }
1612
1613 #[test]
1614 fn known_attached_and_short_global_options_are_skipped() {
1615 assert_eq!(
1616 execute_summary(json!({"command": ["/usr/bin/git", "-Crepo", "status"]})),
1617 "/usr/bin/git status"
1618 );
1619 assert_eq!(
1620 execute_summary(json!({"command": ["git", "-c", "core.pager=cat", "status"]})),
1621 "git status"
1622 );
1623 assert_eq!(
1624 execute_summary(json!({"command": ["gh", "-Rorg/repo", "pr", "list"]})),
1625 "gh pr list"
1626 );
1627 assert_eq!(
1628 execute_summary(json!({"command": ["cargo", "--color=always", "test"]})),
1629 "cargo test"
1630 );
1631 assert_eq!(
1632 execute_summary(json!({"command": ["cargo", "--config", "build.jobs=2", "test"]})),
1633 "cargo test"
1634 );
1635 }
1636
1637 #[test]
1638 fn shell_summary_skips_assignments_arguments_and_nested_substitutions() {
1639 let call = ToolCall::new("call", "Bash")
1640 .kind(ToolKind::Execute)
1641 .raw_input(json!({
1642 "command": "FOO=bar env -i bash -c \"echo $(printf hi)\""
1643 }));
1644 assert_eq!(tool_call_presentation(&call).summary, "env");
1645
1646 let subshell = ToolCall::new("subshell", "Bash")
1647 .kind(ToolKind::Execute)
1648 .raw_input(json!({
1649 "command": "(cd dir && python x.py) | cat"
1650 }));
1651 assert_eq!(
1652 tool_call_presentation(&subshell).summary,
1653 "(cd && python) | cat"
1654 );
1655 }
1656
1657 #[test]
1658 fn shell_summary_keeps_list_pipeline_and_background_operators() {
1659 let call = ToolCall::new("operators", "Bash")
1660 .kind(ToolKind::Execute)
1661 .raw_input(json!({
1662 "command": "'printf' '%s' hi >out |& sed s/hi/bye/ || echo failed & wait; cat <in"
1663 }));
1664
1665 assert_eq!(
1666 tool_call_presentation(&call).summary,
1667 "printf |& sed || echo & wait ; cat"
1668 );
1669 }
1670
1671 #[test]
1672 fn shell_summary_removes_structural_loop_and_group_separators() {
1673 let call = ToolCall::new("compound", "Bash")
1674 .kind(ToolKind::Execute)
1675 .raw_input(json!({
1676 "command": "for file in a b; do rm \"$file\"; done; mkdir -p out; nice -n 10 python3 script.py"
1677 }));
1678
1679 assert_eq!(
1680 tool_call_presentation(&call).summary,
1681 "rm ; mkdir ; nice python3"
1682 );
1683
1684 let conditional = ToolCall::new("conditional", "Bash")
1685 .kind(ToolKind::Execute)
1686 .raw_input(json!({
1687 "command": "if test -f foo; then rm foo; fi; { mkdir bar; echo done; }"
1688 }));
1689 assert_eq!(
1690 tool_call_presentation(&conditional).summary,
1691 "test ; rm ; mkdir ; echo"
1692 );
1693
1694 let case_statement = ToolCall::new("case", "Bash")
1695 .kind(ToolKind::Execute)
1696 .raw_input(json!({
1697 "command": "echo start; case x in a|b) echo branch;; esac; echo done"
1698 }));
1699 assert_eq!(
1700 tool_call_presentation(&case_statement).summary,
1701 "echo ; echo ; echo"
1702 );
1703 }
1704
1705 #[test]
1706 fn shell_summary_handles_a_loop_with_a_leading_pipeline_and_nice() {
1707 let call = ToolCall::new("live-loop-shape", "Bash")
1708 .kind(ToolKind::Execute)
1709 .raw_input(json!({
1710 "command": "cd /tmp && for spec in a b; do set -- $spec; rm \"$spec\"; mkdir -p \"$spec\"; nice -n 10 ./bin/bifrost \"$spec\"; echo \"$spec\"; done; python3 script.py"
1711 }));
1712
1713 assert_eq!(
1714 tool_call_presentation(&call).summary,
1715 "cd && set ; rm ; mkdir ; nice ./bin/bifrost ; echo ; python3"
1716 );
1717 }
1718
1719 #[test]
1720 fn shell_summary_inserts_a_separator_after_a_heredoc() {
1721 let call = ToolCall::new("heredoc", "Bash")
1722 .kind(ToolKind::Execute)
1723 .raw_input(json!({
1724 "command": "python3 <<'PYEOF'\nprint(\"x\")\nPYEOF\ngrep -n x file | head"
1725 }));
1726
1727 assert_eq!(
1728 tool_call_presentation(&call).summary,
1729 "python3 ; grep | head"
1730 );
1731 }
1732
1733 #[test]
1734 fn materialized_summary_from_an_older_parser_is_repaired() {
1735 let source = "python3 <<'PYEOF'\nprint(\"x\")\nPYEOF\ngrep -n x file | head";
1736 let call = ToolCall::new("heredoc", "Bash")
1737 .kind(ToolKind::Execute)
1738 .raw_input(json!({ "command": source }));
1739 let stale = ToolCallPresentation {
1740 summary: "python3 grep | head".into(),
1741 source: source.into(),
1742 source_kind: ToolSummarySourceKind::RawInput,
1743 tool_kind: ToolKind::Execute,
1744 summary_version: 0,
1745 };
1746
1747 let repaired = materialized_tool_call_presentation(Some(&stale), &call);
1748 assert_eq!(repaired.summary, "python3 ; grep | head");
1749 assert_eq!(repaired.summary_version, TOOL_SUMMARY_VERSION);
1750
1751 let mut current = repaired;
1752 current.summary = "stored current summary".into();
1753 assert_eq!(
1754 materialized_tool_call_presentation(Some(¤t), &call).summary,
1755 "stored current summary"
1756 );
1757 }
1758
1759 #[test]
1760 fn nice_summary_skips_its_known_adjustment_options() {
1761 for command in [
1762 vec!["nice", "-n", "10", "python3", "script.py"],
1763 vec!["nice", "--adjustment", "10", "python3", "script.py"],
1764 vec!["nice", "--adjustment=10", "python3", "script.py"],
1765 vec!["nice", "-10", "python3", "script.py"],
1766 vec!["nice", "-n10", "python3", "script.py"],
1767 vec!["nice", "--", "python3", "script.py"],
1768 ] {
1769 assert_eq!(execute_summary(json!({"command": command})), "nice python3");
1770 }
1771
1772 let string = ToolCall::new("nice-string", "Bash")
1773 .kind(ToolKind::Execute)
1774 .raw_input(json!({"command": "nice -n 10 python3 script.py"}));
1775 assert_eq!(tool_call_presentation(&string).summary, "nice python3");
1776 }
1777
1778 #[test]
1779 fn execute_summary_bounds_the_retained_source_before_parsing() {
1780 let command = format!("echo {}", "argument".repeat(TOOL_SUMMARY_SOURCE_BYTES));
1781 let call = ToolCall::new("bounded", "Bash")
1782 .kind(ToolKind::Execute)
1783 .raw_input(json!({ "command": command }));
1784
1785 let presentation = tool_call_presentation(&call);
1786 assert_eq!(presentation.source.len(), TOOL_SUMMARY_SOURCE_BYTES);
1787 assert_eq!(presentation.summary, "echo");
1788 }
1789
1790 #[test]
1791 fn non_execute_titles_use_the_first_meaningful_token() {
1792 let call = ToolCall::new("read", "Read src/lib.rs").kind(ToolKind::Read);
1793 let presentation = tool_call_presentation(&call);
1794 assert_eq!(presentation.summary, "Read");
1795 assert_eq!(presentation.source_kind, ToolSummarySourceKind::Title);
1796 }
1797
1798 #[test]
1799 fn title_wrappers_and_malformed_shell_fall_back_safely() {
1800 let wrapped = ToolCall::new("wrapped", "Running: ls -la").kind(ToolKind::Execute);
1801 assert_eq!(tool_call_presentation(&wrapped).summary, "ls");
1802
1803 let malformed = ToolCall::new("bad", "Bash")
1804 .kind(ToolKind::Execute)
1805 .raw_input(json!({"command": "cd && ("}));
1806 assert_eq!(tool_call_presentation(&malformed).summary, "Bash");
1807 }
1808
1809 #[test]
1810 fn explicit_empty_raw_input_drops_a_stale_raw_summary() {
1811 let initial = ToolCall::new("call", "Bash")
1812 .kind(ToolKind::Execute)
1813 .raw_input(json!({"command": "python script.py"}));
1814 let previous = tool_call_presentation(&initial);
1815 let empty_input = json!({"command": null});
1816 let updated = update_tool_call_presentation(
1817 Some(&previous),
1818 "Running: ls -la",
1819 None,
1820 Some(&empty_input),
1821 None,
1822 );
1823 assert_eq!(updated.summary, "ls");
1824 assert_eq!(updated.source_kind, ToolSummarySourceKind::Title);
1825
1826 let output = json!({"command": "cat result.txt"});
1827 let updated = update_tool_call_presentation(
1828 Some(&previous),
1829 "Running: ls -la",
1830 None,
1831 Some(&empty_input),
1832 Some(&output),
1833 );
1834 assert_eq!(updated.summary, "cat");
1835 assert_eq!(updated.source_kind, ToolSummarySourceKind::RawOutput);
1836
1837 let output_initial = ToolCall::new("output", "Bash")
1838 .kind(ToolKind::Execute)
1839 .raw_output(json!({"command": "python result.py"}));
1840 let output_previous = tool_call_presentation(&output_initial);
1841 let empty_output = json!({"command": null});
1842 let updated = update_tool_call_presentation(
1843 Some(&output_previous),
1844 "Running: ls -la",
1845 None,
1846 None,
1847 Some(&empty_output),
1848 );
1849 assert_eq!(updated.summary, "ls");
1850 assert_eq!(updated.source_kind, ToolSummarySourceKind::Title);
1851 }
1852}