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