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 = 4;
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)]
536struct InvocationArgument {
537 value: String,
538 literal: bool,
539}
540
541fn summarize_command_node(node: Node<'_>, source: &str) -> Option<String> {
542 let name = node.child_by_field_name("name")?;
543 let executable = shell_command_name(name, source)?;
544 let mut cursor = node.walk();
545 let arguments = node
546 .children_by_field_name("argument", &mut cursor)
547 .map(|argument| match shell_argument_value(argument, source) {
548 Some(value) => InvocationArgument {
549 value,
550 literal: true,
551 },
552 None => InvocationArgument {
553 value: strip_matching_quotes(source[argument.byte_range()].trim()).to_owned(),
554 literal: false,
555 },
556 })
557 .collect::<Vec<_>>();
558 summarize_invocation_with_literals(&executable, &arguments)
559}
560
561fn shell_command_name(node: Node<'_>, source: &str) -> Option<String> {
562 if contains_dynamic_shell_node(node) {
563 return None;
564 }
565 normalize_command_name(&source[node.byte_range()])
566}
567
568fn shell_argument_value(node: Node<'_>, source: &str) -> Option<String> {
569 if contains_dynamic_shell_node(node) {
570 return None;
571 }
572 let text = source[node.byte_range()].trim();
573 if text.is_empty() {
574 return None;
575 }
576 Some(strip_matching_quotes(text).to_owned())
577}
578
579fn contains_dynamic_shell_node(node: Node<'_>) -> bool {
580 if matches!(
581 node.kind(),
582 "expansion"
583 | "simple_expansion"
584 | "command_substitution"
585 | "process_substitution"
586 | "arithmetic_expansion"
587 ) {
588 return true;
589 }
590 let mut cursor = node.walk();
591 node.children(&mut cursor).any(contains_dynamic_shell_node)
592}
593
594fn summarize_invocation(executable: &str, arguments: &[String]) -> Option<String> {
595 summarize_invocation_with_literals(
596 executable,
597 &arguments
598 .iter()
599 .map(|value| InvocationArgument {
600 value: strip_matching_quotes(value).to_owned(),
601 literal: true,
602 })
603 .collect::<Vec<_>>(),
604 )
605}
606
607fn summarize_invocation_with_literals(
608 executable: &str,
609 arguments: &[InvocationArgument],
610) -> Option<String> {
611 let executable = normalize_command_name(executable)?;
612 let basename = executable
613 .rsplit(['/', '\\'])
614 .next()
615 .unwrap_or(&executable)
616 .to_owned();
617 if is_shell_interpreter(&basename) {
618 return Some(summarize_shell_invocation(&basename, arguments));
619 }
620 let mut words = vec![executable];
621 if !is_summary_executable(&basename) {
622 return Some(words.remove(0));
623 }
624 let mut index = 0;
625
626 if matches!(basename.as_str(), "cargo" | "mbx")
627 && arguments.first().is_some_and(|argument| {
628 argument.literal && argument.value.starts_with('+') && argument.value.len() > 1
629 })
630 {
631 index += 1;
632 }
633
634 let first_verb = loop {
635 let Some(argument) = arguments.get(index) else {
636 return Some(words.remove(0));
637 };
638 if !argument.literal {
639 return Some(words.remove(0));
640 }
641 if argument.value == "--" || argument.value.starts_with('-') {
642 if let Some(consumed) = known_leading_option_arguments(&basename, arguments, index) {
643 index += consumed;
644 continue;
645 }
646 return Some(words.remove(0));
647 }
648 break argument.value.clone();
649 };
650 words.push(first_verb.clone());
651
652 if allows_second_verb(&basename, &first_verb) {
653 let first = index + 1;
654 if let Some(argument) = arguments.get(first)
655 && argument.literal
656 && !argument.value.starts_with('-')
657 && argument.value != "--"
658 {
659 let verb = if basename == "uv" && first_verb == "run" {
661 argument.value.rsplit('/').next().unwrap_or(&argument.value)
662 } else {
663 &argument.value
664 };
665 words.push(verb.to_owned());
666 }
667 }
668 Some(words.join(" "))
669}
670
671fn summarize_shell_invocation(shell: &str, arguments: &[InvocationArgument]) -> String {
676 let values = arguments
677 .iter()
678 .map(|argument| argument.value.as_str())
679 .collect::<Vec<_>>();
680 let Some(script) = shell_script_argument(&values) else {
681 return shell.to_owned();
682 };
683 match summarize_shell(script) {
684 Some(inner) => format!("{shell} -c {inner}"),
685 None => format!("{shell} -c"),
686 }
687}
688
689fn is_summary_executable(basename: &str) -> bool {
690 matches!(
691 basename,
692 "git"
693 | "gh"
694 | "cargo"
695 | "rustup"
696 | "npm"
697 | "pnpm"
698 | "yarn"
699 | "bun"
700 | "uv"
701 | "pip"
702 | "pip3"
703 | "docker"
704 | "podman"
705 | "nice"
706 | "mbx"
707 | "mj"
708 | "tmux"
709 )
710}
711
712fn allows_second_verb(basename: &str, first_verb: &str) -> bool {
713 match basename {
714 "git" => matches!(
717 first_verb,
718 "bisect"
719 | "bundle"
720 | "hook"
721 | "lfs"
722 | "maintenance"
723 | "notes"
724 | "remote"
725 | "sparse-checkout"
726 | "stash"
727 | "submodule"
728 | "worktree"
729 ),
730 "gh" => matches!(
731 first_verb,
732 "alias"
733 | "auth"
734 | "cache"
735 | "codespace"
736 | "config"
737 | "extension"
738 | "gist"
739 | "gpg-key"
740 | "issue"
741 | "label"
742 | "org"
743 | "pr"
744 | "project"
745 | "release"
746 | "repo"
747 | "ruleset"
748 | "run"
749 | "search"
750 | "secret"
751 | "ssh-key"
752 | "variable"
753 | "workflow"
754 ),
755 "docker" => matches!(
756 first_verb,
757 "buildx"
758 | "compose"
759 | "config"
760 | "context"
761 | "container"
762 | "image"
763 | "manifest"
764 | "network"
765 | "node"
766 | "plugin"
767 | "secret"
768 | "service"
769 | "stack"
770 | "swarm"
771 | "system"
772 | "trust"
773 | "volume"
774 ),
775 "podman" => matches!(
776 first_verb,
777 "artifact"
778 | "container"
779 | "farm"
780 | "generate"
781 | "image"
782 | "machine"
783 | "manifest"
784 | "network"
785 | "play"
786 | "pod"
787 | "secret"
788 | "system"
789 | "volume"
790 ),
791 "uv" => matches!(first_verb, "cache" | "pip" | "python" | "run" | "tool"),
792 "npm" => matches!(first_verb, "run" | "run-script"),
793 "pnpm" | "yarn" | "bun" => first_verb == "run",
794 "mbx" => matches!(first_verb, "cache" | "explain" | "settings"),
795 "mj" => matches!(
796 first_verb,
797 "daemon" | "import" | "recover" | "review" | "setup" | "workspaces"
798 ),
799 "rustup" => matches!(
800 first_verb,
801 "component" | "override" | "target" | "toolchain"
802 ),
803 _ => false,
804 }
805}
806
807fn known_leading_option_arguments(
808 basename: &str,
809 arguments: &[InvocationArgument],
810 index: usize,
811) -> Option<usize> {
812 let option = arguments.get(index)?.value.as_str();
813 if option == "--" {
814 if basename == "nice" {
815 return Some(1);
816 }
817 return None;
818 }
819 let (option_name, attached_value) = option
820 .split_once('=')
821 .map_or((option, false), |(name, _)| (name, true));
822 if basename == "nice"
823 && option.starts_with('-')
824 && option.len() > 1
825 && option[1..].parse::<i32>().is_ok()
826 {
827 return Some(1);
828 }
829 if basename == "nice"
830 && option
831 .strip_prefix("-n")
832 .is_some_and(|value| !value.is_empty() && value.parse::<i32>().is_ok())
833 {
834 return Some(1);
835 }
836 let attached_short_value = match basename {
837 "git" => option.starts_with("-C") || option.starts_with("-c"),
838 "gh" => option.starts_with("-R"),
839 "docker" | "podman" => option.starts_with("-H"),
840 "mj" => option.starts_with("-i"),
841 "tmux" => ["-L", "-S", "-f", "-c", "-T"]
842 .iter()
843 .any(|prefix| option.starts_with(prefix)),
844 _ => false,
845 } && option.len() > 2;
846 let takes_value = match basename {
847 "git" => matches!(
848 option_name,
849 "-C" | "-c"
850 | "--config-env"
851 | "--exec-path"
852 | "--git-dir"
853 | "--namespace"
854 | "--super-prefix"
855 | "--work-tree"
856 ),
857 "gh" => matches!(
858 option_name,
859 "-R" | "--hostname" | "--repo" | "--jq" | "--template"
860 ),
861 "cargo" => matches!(
862 option_name,
863 "--manifest-path" | "--target-dir" | "--config" | "--color"
864 ),
865 "npm" | "pnpm" | "yarn" | "bun" => {
866 matches!(
867 option_name,
868 "--cwd" | "--dir" | "--prefix" | "--registry" | "--userconfig"
869 )
870 }
871 "uv" => matches!(option_name, "--directory" | "--project" | "--python"),
872 "rustup" => matches!(option_name, "--toolchain"),
873 "mj" => matches!(option_name, "-i" | "--instance" | "--workspace"),
874 "tmux" => matches!(option_name, "-L" | "-S" | "-f" | "-c" | "-T"),
875 "nice" => {
876 matches!(option_name, "-n" | "--adjustment")
877 }
878 "docker" | "podman" => matches!(
879 option_name,
880 "-H" | "--config" | "--connection" | "--context" | "--host" | "--log-level"
881 ),
882 _ => false,
883 };
884 if attached_short_value {
885 return Some(1);
886 }
887 if attached_value {
888 return takes_value.then_some(1);
889 }
890 if takes_value {
891 return arguments
892 .get(index + 1)
893 .filter(|argument| argument.literal)
894 .map(|_| 2);
895 }
896 let known_flag = match basename {
897 "git" => matches!(
898 option_name,
899 "-p" | "--paginate"
900 | "-P"
901 | "--no-pager"
902 | "--bare"
903 | "--literal-pathspecs"
904 | "--glob-pathspecs"
905 | "--noglob-pathspecs"
906 | "--icase-pathspecs"
907 | "--no-optional-locks"
908 | "--no-advice"
909 ),
910 "gh" => false,
911 "cargo" => matches!(
912 option_name,
913 "-q" | "--quiet" | "-v" | "--verbose" | "--locked" | "--offline" | "--frozen"
914 ),
915 "npm" | "pnpm" | "yarn" | "bun" => {
916 matches!(option_name, "-g" | "--global" | "--silent")
917 }
918 "uv" => matches!(
919 option_name,
920 "-q" | "--quiet" | "-v" | "--verbose" | "--offline"
921 ),
922 "rustup" => matches!(option_name, "-q" | "--quiet" | "-v" | "--verbose"),
923 "docker" | "podman" => matches!(option_name, "-D" | "--debug" | "--tls"),
924 "tmux" => matches!(
925 option_name,
926 "-2" | "-C" | "-CC" | "-D" | "-l" | "-N" | "-u" | "-v"
927 ),
928 "nice" => false,
929 _ => false,
930 };
931 known_flag.then_some(1)
932}
933
934fn strip_matching_quotes(value: &str) -> &str {
935 value
936 .strip_prefix('"')
937 .and_then(|value| value.strip_suffix('"'))
938 .or_else(|| {
939 value
940 .strip_prefix('\'')
941 .and_then(|value| value.strip_suffix('\''))
942 })
943 .unwrap_or(value)
944}
945
946fn is_shell_operator(node: Node<'_>) -> bool {
947 match node.kind() {
948 ";" => true,
949 "&&" | "||" => node.parent().is_some_and(|parent| parent.kind() == "list"),
950 "|" | "|&" => node
951 .parent()
952 .is_some_and(|parent| parent.kind() == "pipeline"),
953 "&" => node.parent().is_none_or(|parent| {
954 !matches!(
955 parent.kind(),
956 "binary_expression" | "unary_expression" | "postfix_expression"
957 )
958 }),
959 _ => false,
960 }
961}
962
963fn normalize_command_name(text: &str) -> Option<String> {
964 let text = text.trim();
965 if text.contains('$') || text.contains('`') {
966 return None;
967 }
968 let text = text
969 .strip_prefix('"')
970 .and_then(|value| value.strip_suffix('"'))
971 .or_else(|| {
972 text.strip_prefix('\'')
973 .and_then(|value| value.strip_suffix('\''))
974 })
975 .unwrap_or(text);
976 (!text.is_empty()).then(|| text.to_owned())
977}
978
979fn join_shell_tokens(tokens: Vec<String>) -> String {
980 let mut output = String::new();
981 for token in tokens {
982 match token.as_str() {
983 "(" => {
984 if !output.is_empty() && !output.ends_with(' ') {
985 output.push(' ');
986 }
987 output.push('(');
988 }
989 ")" => {
990 output = output.trim_end().to_owned();
991 output.push(')');
992 }
993 _ => {
994 if !output.is_empty() && !output.ends_with(' ') && !output.ends_with('(') {
995 output.push(' ');
996 }
997 output.push_str(&token);
998 }
999 }
1000 }
1001 output
1002}
1003
1004pub fn tool_input_text(input: &Value) -> String {
1007 fn value_text(value: &Value) -> String {
1008 match value {
1009 Value::String(text) => text.clone(),
1010 _ => serde_json::to_string_pretty(value)
1011 .expect("tool arguments are serializable JSON values"),
1012 }
1013 }
1014
1015 let text = match input {
1016 Value::Object(fields) if !fields.is_empty() => fields
1017 .iter()
1018 .map(|(name, value)| format!("{name}:\n{}", value_text(value)))
1019 .collect::<Vec<_>>()
1020 .join("\n\n"),
1021 _ => value_text(input),
1022 };
1023 sanitize_terminal_text(&text)
1024}
1025
1026pub fn tool_content_details(
1027 content: &[ToolCallContent],
1028 terminal_outputs: &[TerminalOutputRecord],
1029 raw_output: Option<&serde_json::Value>,
1030) -> Vec<String> {
1031 let mut details = Vec::new();
1032 let mut referenced: Vec<&str> = Vec::new();
1033 for item in content {
1034 let detail = match item {
1035 ToolCallContent::Content(content) => content_block_text(&content.content),
1036 ToolCallContent::Diff(_) => None,
1037 ToolCallContent::Terminal(terminal) => {
1041 let terminal_id = terminal.terminal_id.0.as_ref();
1042 referenced.push(terminal_id);
1043 Some(
1044 terminal_outputs
1045 .iter()
1046 .find(|record| record.terminal_id.as_str() == terminal_id)
1047 .map(terminal_output_detail)
1048 .or_else(|| raw_output.and_then(raw_output_terminal_detail))
1049 .unwrap_or_else(|| format!("terminal {}", terminal.terminal_id)),
1050 )
1051 }
1052 _ => None,
1053 };
1054 if let Some(detail) = detail {
1055 details.push(sanitize_terminal_text(&detail));
1056 }
1057 }
1058 for record in terminal_outputs {
1062 if referenced.contains(&record.terminal_id.as_str()) {
1063 continue;
1064 }
1065 let output = sanitize_terminal_text(&record.output);
1066 if !output.is_empty() && details.iter().any(|detail| detail == &output) {
1067 details.push(terminal_exit_summary(record));
1071 } else {
1072 details.push(sanitize_terminal_text(&terminal_output_detail(record)));
1073 }
1074 }
1075 details
1076}
1077
1078fn raw_output_terminal_detail(raw_output: &serde_json::Value) -> Option<String> {
1083 let output = raw_output.get("formatted_output")?.as_str()?;
1084 let Some(exit_code) = raw_output
1085 .get("exit_code")
1086 .and_then(serde_json::Value::as_i64)
1087 else {
1088 return Some(output.to_owned());
1089 };
1090 let summary = format!("exited {exit_code}");
1091 if output.is_empty() {
1092 return Some(summary);
1093 }
1094 Some(format!("{output}\n{summary}"))
1095}
1096
1097pub fn terminal_output_detail(record: &TerminalOutputRecord) -> String {
1099 let summary = terminal_exit_summary(record);
1100 if record.output.is_empty() {
1101 return summary;
1102 }
1103 format!("{}\n{summary}", record.output)
1104}
1105
1106fn terminal_exit_summary(record: &TerminalOutputRecord) -> String {
1108 let mut summary = match (record.exit_code, &record.signal) {
1109 (_, Some(signal)) => format!("killed by {signal}"),
1110 (Some(code), None) => format!("exited {code}"),
1111 (None, None) => "released before exit".to_owned(),
1112 };
1113 if record.truncated {
1114 summary.push_str(" · output truncated");
1115 }
1116 summary
1117}
1118
1119pub fn tool_diff_paths(content: &[ToolCallContent]) -> Vec<String> {
1120 content
1121 .iter()
1122 .filter_map(|item| match item {
1123 ToolCallContent::Diff(diff) => Some(diff.path.display().to_string()),
1124 _ => None,
1125 })
1126 .collect()
1127}
1128
1129pub fn tool_location_details(locations: &[ToolCallLocation]) -> Vec<String> {
1130 locations
1131 .iter()
1132 .map(|location| match location.line {
1133 Some(line) => format!("{}:{line}", location.path.display()),
1134 None => location.path.display().to_string(),
1135 })
1136 .collect()
1137}
1138
1139pub(crate) fn push_streamed_entry(
1143 entries: &mut Vec<ChatEntry>,
1144 seq: u64,
1145 recorded_at_ms: Option<i64>,
1146 role: ChatRole,
1147 message_id: Option<String>,
1148 text: &str,
1149) {
1150 let text = sanitize_terminal_text(text);
1151 if let Some(last) = entries.last_mut()
1152 && last.role == role
1153 && (role == ChatRole::Thought || last.message_id == message_id)
1154 {
1155 last.touch(seq);
1156 if role == ChatRole::Thought
1157 && last.message_id != message_id
1158 && !last.text.is_empty()
1159 && !text.is_empty()
1160 {
1161 while last.text.ends_with('\n') {
1162 last.text.pop();
1163 }
1164 last.text.push('\n');
1165 last.text.push_str(text.trim_start_matches('\n'));
1166 } else {
1167 last.text.push_str(&text);
1168 }
1169 return;
1170 }
1171 let mut entry = ChatEntry::plain(seq, role, text).with_recorded_at(recorded_at_ms);
1172 entry.message_id = message_id;
1173 entries.push(entry);
1174}
1175
1176pub fn apply_session_update_to_entries(
1180 entries: &mut Vec<ChatEntry>,
1181 seq: u64,
1182 recorded_at_ms: Option<i64>,
1183 update: SessionUpdate,
1184) -> Option<SessionUpdate> {
1185 match update {
1186 SessionUpdate::AgentMessageChunk(chunk) => {
1187 let message_id = chunk.message_id.map(|id| id.to_string());
1188 if let Some(text) = content_block_text(&chunk.content) {
1189 push_streamed_entry(
1190 entries,
1191 seq,
1192 recorded_at_ms,
1193 ChatRole::Agent,
1194 message_id,
1195 &text,
1196 );
1197 }
1198 }
1199 SessionUpdate::AgentThoughtChunk(chunk) => {
1200 let message_id = chunk.message_id.map(|id| id.to_string());
1201 if let Some(text) = content_block_text(&chunk.content) {
1202 push_streamed_entry(
1203 entries,
1204 seq,
1205 recorded_at_ms,
1206 ChatRole::Thought,
1207 message_id,
1208 &text,
1209 );
1210 }
1211 }
1212 SessionUpdate::UserMessageChunk(_) => {}
1215 SessionUpdate::ToolCall(call) => {
1216 let presentation = tool_call_presentation(&call);
1217 let mut entry = ChatEntry::tool(
1218 seq,
1219 call.title,
1220 Some(call.tool_call_id.to_string()),
1221 tool_status(&call.status),
1222 );
1223 entry.tool_summary = Some(presentation.summary.clone());
1224 entry.tool_presentation = Some(presentation);
1225 entry.tool_input = call.raw_input;
1226 entry.tool_content = tool_content_details(&call.content, &[], call.raw_output.as_ref());
1227 entry.tool_diffstats = tool_diff_paths(&call.content);
1228 entry.tool_locations = tool_location_details(&call.locations);
1229 entries.push(entry);
1230 }
1231 SessionUpdate::ToolCallUpdate(update) => {
1232 let tool_call_id = update.tool_call_id.to_string();
1233 let entry = entries.iter_mut().rev().find(|entry| {
1234 entry.role == ChatRole::Tool
1235 && entry.tool_call_id.as_deref() == Some(tool_call_id.as_str())
1236 })?;
1237 entry.touch(seq);
1238 let kind = update.fields.kind;
1239 let raw_input = update.fields.raw_input.clone();
1240 let raw_output = update.fields.raw_output.clone();
1241 if let Some(input) = &raw_input {
1242 entry.tool_input = Some(input.clone());
1243 }
1244 if let Some(title) = update.fields.title {
1245 entry.text = sanitize_terminal_text(&title);
1246 }
1247 if let Some(status) = update.fields.status {
1248 entry.tool_status = Some(tool_status(&status));
1249 }
1250 if let Some(content) = update.fields.content {
1251 entry.tool_content =
1252 tool_content_details(&content, &[], update.fields.raw_output.as_ref());
1253 entry.tool_diffstats = tool_diff_paths(&content);
1254 }
1255 if let Some(locations) = update.fields.locations {
1256 entry.tool_locations = tool_location_details(&locations);
1257 }
1258 let presentation = update_tool_call_presentation(
1259 entry.tool_presentation.as_ref(),
1260 &entry.text,
1261 kind,
1262 raw_input.as_ref(),
1263 raw_output.as_ref(),
1264 );
1265 entry.tool_summary = Some(presentation.summary.clone());
1266 entry.tool_presentation = Some(presentation);
1267 }
1268 SessionUpdate::Plan(plan) => {
1269 let lines = plan
1270 .entries
1271 .into_iter()
1272 .map(|entry| PlanLine {
1273 text: sanitize_terminal_text(&entry.content),
1274 status: plan_status(&entry.status),
1275 })
1276 .collect();
1277 let latest_user_seq = entries
1278 .iter()
1279 .rev()
1280 .find(|entry| entry.role == ChatRole::User)
1281 .map_or(0, |entry| entry.seq);
1282 if let Some(entry) = entries
1283 .iter_mut()
1284 .rev()
1285 .find(|entry| entry.role == ChatRole::Plan && entry.seq > latest_user_seq)
1286 {
1287 entry.touch(seq);
1288 entry.plan = lines;
1289 } else {
1290 entries.push(ChatEntry::plan(seq, lines));
1291 }
1292 }
1293 other => return Some(other),
1294 }
1295 None
1296}
1297
1298pub fn apply_runtime_event_to_entries(
1302 entries: &mut Vec<ChatEntry>,
1303 seq: u64,
1304 recorded_at_ms: Option<i64>,
1305 runtime: RuntimeEvent,
1306) -> Option<RuntimeEvent> {
1307 match runtime {
1308 RuntimeEvent::SessionUpdate { update } => {
1309 let parsed = match serde_json::from_value::<SessionUpdate>(update.clone()) {
1310 Ok(parsed) => parsed,
1311 Err(error) => {
1312 tracing::debug!(%error, "ignoring invalid ACP session update");
1313 return None;
1314 }
1315 };
1316 apply_session_update_to_entries(entries, seq, recorded_at_ms, parsed)
1317 .map(|_| RuntimeEvent::SessionUpdate { update })
1318 }
1319 RuntimeEvent::Warning { message } | RuntimeEvent::SessionFault { message } => {
1320 entries.push(ChatEntry::plain(
1321 seq,
1322 ChatRole::System,
1323 format!("warning: {message}"),
1324 ));
1325 None
1326 }
1327 RuntimeEvent::ConfigApplied { key, value, .. } => {
1328 entries.push(ChatEntry::plain(
1329 seq,
1330 ChatRole::System,
1331 format!("{key} set to {value}"),
1332 ));
1333 None
1334 }
1335 RuntimeEvent::SessionStarted { resumed: false, .. } => {
1336 entries.push(ChatEntry::plain(
1337 seq,
1338 ChatRole::System,
1339 "harness session started",
1340 ));
1341 None
1342 }
1343 RuntimeEvent::SessionStarted { resumed: true, .. } => None,
1344 RuntimeEvent::ElicitationResolved {
1345 reply: Some(reply), ..
1346 } => {
1347 let mut entry =
1348 ChatEntry::plain(seq, ChatRole::User, reply).with_recorded_at(recorded_at_ms);
1349 entry.message_id = Some(format!("{ELICITATION_REPLY_ITEM_PREFIX}{seq}"));
1350 entries.push(entry);
1351 None
1352 }
1353 other => Some(other),
1354 }
1355}
1356
1357pub fn transcript_item_text(item: &TranscriptItem) -> String {
1363 match &item.body {
1364 TranscriptBody::User { content } => materialized_content_text(content),
1365 TranscriptBody::Message { event } => format!(
1366 "{}\n\n{}",
1367 event.message_label(),
1368 mj_core::transcript::sanitize_terminal_text(
1369 event.message_text().expect("message entry metadata")
1370 )
1371 ),
1372 TranscriptBody::Agent { chunks, .. } | TranscriptBody::Thought { chunks, .. } => {
1373 materialized_chunks_text(chunks)
1374 }
1375 TranscriptBody::Tool {
1376 call,
1377 terminal_outputs,
1378 presentation,
1379 ..
1380 } => {
1381 let Ok(call) = ToolCall::deserialize(call) else {
1382 return "[invalid tool call]".to_owned();
1383 };
1384 let mut text = materialized_tool_call_presentation(presentation.as_deref(), &call)
1385 .summary
1386 .clone();
1387 if text.trim().is_empty() {
1388 text = call.title.clone();
1389 }
1390 for record in terminal_outputs {
1391 text.push('\n');
1392 text.push_str(&terminal_output_detail(record));
1393 }
1394 sanitize_terminal_text(&text)
1395 }
1396 TranscriptBody::TerminalOutput { record } => {
1397 sanitize_terminal_text(&terminal_output_detail(record))
1398 }
1399 TranscriptBody::Plan { plan } => {
1400 let Ok(plan) = agent_client_protocol::schema::v1::Plan::deserialize(plan) else {
1401 return String::new();
1402 };
1403 plan.entries
1404 .iter()
1405 .map(|entry| {
1406 let status = match plan_status(&entry.status) {
1407 PlanStatus::Pending => "pending",
1408 PlanStatus::Running => "running",
1409 PlanStatus::Completed => "completed",
1410 };
1411 format!("[{status}] {}", sanitize_terminal_text(&entry.content))
1412 })
1413 .collect::<Vec<_>>()
1414 .join("\n")
1415 }
1416 TranscriptBody::PlanProposal { plan, .. } => plan.clone(),
1417 TranscriptBody::System { text } => text.clone(),
1418 }
1419}
1420
1421pub(crate) fn compute_tool_diffstats(content: &[ToolCallContent]) -> Vec<String> {
1422 content
1423 .iter()
1424 .filter_map(|item| match item {
1425 ToolCallContent::Diff(diff) => Some(format_diffstat(diff)),
1426 _ => None,
1427 })
1428 .collect()
1429}
1430
1431pub fn materialized_tool_diffstats(item: &TranscriptItem) -> Option<Vec<String>> {
1432 let TranscriptBody::Tool { call, .. } = &item.body else {
1433 return None;
1434 };
1435 let call = match ToolCall::deserialize(call) {
1436 Ok(call) => call,
1437 Err(error) => {
1438 tracing::warn!(
1439 stable_id = %item.stable_id,
1440 %error,
1441 "could not decode a stored tool call while reading diff summary"
1442 );
1443 return None;
1444 }
1445 };
1446 if !matches!(
1447 tool_status(&call.status),
1448 ToolStatus::Completed | ToolStatus::Failed
1449 ) {
1450 return None;
1451 }
1452 let diffstats = compute_tool_diffstats(&call.content);
1453 (!diffstats.is_empty()).then_some(diffstats)
1454}
1455
1456fn format_diffstat(diff: &agent_client_protocol::schema::v1::Diff) -> String {
1457 let patch = mj_core::diff::patch_of(diff);
1461 format!(
1462 "{} +{} −{}",
1463 diff.path.display(),
1464 patch.insertions,
1465 patch.deletions
1466 )
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471 use super::*;
1472 use agent_client_protocol::schema::v1::{ToolCall, ToolCallStatus};
1473 use serde_json::json;
1474
1475 #[test]
1479 fn a_question_tool_is_summarized_by_its_whole_question() {
1480 let call = ToolCall::new("ask", "Which file name should I use for the new file?")
1481 .raw_input(json!({"questions": [{"question": "Which file name should I use for the new file?", "header": "File name"}]}));
1482 assert_eq!(
1483 tool_call_presentation(&call).summary,
1484 "Which file name should I use for the new file?"
1485 );
1486 let read = ToolCall::new("read", "Read src/lib.rs");
1487 assert_eq!(tool_call_presentation(&read).summary, "Read");
1488 let stale = ToolCallPresentation {
1489 summary: "Which".into(),
1490 source: call.title.clone(),
1491 source_kind: ToolSummarySourceKind::Title,
1492 tool_kind: ToolKind::Other,
1493 summary_version: 1,
1494 };
1495 assert_eq!(
1496 materialized_tool_call_presentation(Some(&stale), &call).summary,
1497 "Which file name should I use for the new file?",
1498 "stored rows written by the old rules are repaired"
1499 );
1500 }
1501
1502 fn execute_summary(command: serde_json::Value) -> String {
1503 let call = ToolCall::new("argv", "Bash")
1504 .kind(ToolKind::Execute)
1505 .raw_input(command);
1506 tool_call_presentation(&call).summary
1507 }
1508
1509 #[test]
1510 fn unknown_leading_options_make_verb_position_ambiguous() {
1511 assert_eq!(
1512 execute_summary(json!({"command": ["git", "--mystery", "status"]})),
1513 "git"
1514 );
1515 assert_eq!(
1516 execute_summary(json!({"command": ["cargo", "--mystery", "test"]})),
1517 "cargo"
1518 );
1519 }
1520
1521 #[test]
1522 fn git_commands_with_nested_subcommands_show_both_verbs() {
1523 let cases = [
1524 ("git worktree add ../x mj/session", "git worktree add"),
1525 ("git -C repo worktree list", "git worktree list"),
1526 ("git stash push -u -m tag", "git stash push"),
1527 ("git stash", "git stash"),
1528 ("git remote -v", "git remote"),
1529 ("git bisect good abc123", "git bisect good"),
1530 ("git stash -- src", "git stash"),
1531 ("git log origin/master", "git log"),
1532 ("git config user.name", "git config"),
1533 ];
1534 for (command, summary) in cases {
1535 assert_eq!(
1536 execute_summary(json!({"command": command})),
1537 summary,
1538 "{command}"
1539 );
1540 }
1541 }
1542
1543 #[test]
1544 fn project_tools_and_script_runners_show_their_subcommands() {
1545 let cases = [
1546 ("mbx nextest run -p brokk-mj-tui", "mbx nextest"),
1547 ("mbx +1.91 build bifrost", "mbx build"),
1548 ("mbx cache stats --json", "mbx cache stats"),
1549 ("mj daemon restart", "mj daemon restart"),
1550 (
1551 "mj -i test --workspace hel daemon status",
1552 "mj daemon status",
1553 ),
1554 ("mj transcript abc123", "mj transcript"),
1555 ("tmux -L mjv-e send-keys -t 0 q", "tmux send-keys"),
1556 ("tmux -Lmjv capture-pane -p", "tmux capture-pane"),
1557 ("tmux -S /tmp/sock -2 new-session -d", "tmux new-session"),
1558 ("uv run pytest -q", "uv run pytest"),
1559 ("uv run ./scripts/check.py", "uv run check.py"),
1560 ("uv run --with x pytest", "uv run"),
1561 ("npm run build", "npm run build"),
1562 ("npm run-script test:e2e", "npm run-script test:e2e"),
1563 ("pnpm run lint", "pnpm run lint"),
1564 ("npm test", "npm test"),
1565 ];
1566 for (command, summary) in cases {
1567 assert_eq!(
1568 execute_summary(json!({"command": command})),
1569 summary,
1570 "{command}"
1571 );
1572 }
1573 }
1574
1575 #[test]
1576 fn a_nested_shell_is_named_without_its_path_and_shows_its_script() {
1577 let cases = [
1578 ("bash -c 'python x.py'", "bash -c python"),
1579 ("/bin/bash -c \"python x.py\"", "bash -c python"),
1580 (
1581 "/usr/bin/zsh -lc 'git status && cargo test'",
1582 "zsh -c git status && cargo test",
1583 ),
1584 ("bash -c \"cd $dir && make\"", "bash -c cd && make"),
1585 ("bash -c \"$script\"", "bash -c"),
1586 ("/bin/bash script.sh", "bash"),
1587 (
1588 "bash -c 'bash -c \"uv run pytest\"'",
1589 "bash -c bash -c uv run pytest",
1590 ),
1591 ];
1592 for (command, summary) in cases {
1593 assert_eq!(
1594 execute_summary(json!({"command": command})),
1595 summary,
1596 "{command}"
1597 );
1598 }
1599 assert_eq!(
1601 execute_summary(json!({"command": ["/bin/bash", "-lc", "python x.py"]})),
1602 "python"
1603 );
1604 assert_eq!(
1605 execute_summary(json!({"command": ["/bin/zsh", "script.sh"]})),
1606 "zsh"
1607 );
1608 }
1609
1610 #[test]
1611 fn quoted_and_dynamic_shell_verbs_are_distinguished() {
1612 let quoted = ToolCall::new("quoted", "Bash")
1613 .kind(ToolKind::Execute)
1614 .raw_input(json!({"command": "git \"status\""}));
1615 assert_eq!(tool_call_presentation("ed).summary, "git status");
1616
1617 let dynamic = ToolCall::new("dynamic", "Bash")
1618 .kind(ToolKind::Execute)
1619 .raw_input(json!({"command": "git \"$verb\""}));
1620 assert_eq!(tool_call_presentation(&dynamic).summary, "git");
1621
1622 let dynamic_name = ToolCall::new("dynamic-name", "Bash")
1623 .kind(ToolKind::Execute)
1624 .raw_input(json!({"command": "$command status"}));
1625 assert_eq!(tool_call_presentation(&dynamic_name).summary, "Bash");
1626 }
1627
1628 #[test]
1629 fn shell_summary_skips_assignments_arguments_and_nested_substitutions() {
1630 let call = ToolCall::new("call", "Bash")
1631 .kind(ToolKind::Execute)
1632 .raw_input(json!({
1633 "command": "FOO=bar env -i bash -c \"echo $(printf hi)\""
1634 }));
1635 assert_eq!(tool_call_presentation(&call).summary, "env");
1636
1637 let subshell = ToolCall::new("subshell", "Bash")
1638 .kind(ToolKind::Execute)
1639 .raw_input(json!({
1640 "command": "(cd dir && python x.py) | cat"
1641 }));
1642 assert_eq!(
1643 tool_call_presentation(&subshell).summary,
1644 "(cd && python) | cat"
1645 );
1646 }
1647
1648 #[test]
1649 fn shell_summary_keeps_list_pipeline_and_background_operators() {
1650 let simple = ToolCall::new("call-1", "Bash")
1651 .kind(ToolKind::Execute)
1652 .raw_input(json!({
1653 "command": "cd dir && python x.py | cat | wc ; print ok"
1654 }));
1655 let presentation = tool_call_presentation(&simple);
1656 assert_eq!(presentation.summary, "cd && python | cat | wc ; print");
1657 assert_eq!(presentation.source_kind, ToolSummarySourceKind::RawInput);
1658
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 execute_summary_bounds_the_retained_source_before_parsing() {
1761 let command = format!("echo {}", "argument".repeat(TOOL_SUMMARY_SOURCE_BYTES));
1762 let call = ToolCall::new("bounded", "Bash")
1763 .kind(ToolKind::Execute)
1764 .raw_input(json!({ "command": command }));
1765
1766 let presentation = tool_call_presentation(&call);
1767 assert_eq!(presentation.source.len(), TOOL_SUMMARY_SOURCE_BYTES);
1768 assert_eq!(presentation.summary, "echo");
1769 }
1770
1771 #[test]
1772 fn title_wrappers_and_malformed_shell_fall_back_safely() {
1773 let wrapped = ToolCall::new("wrapped", "Running: ls -la").kind(ToolKind::Execute);
1774 assert_eq!(tool_call_presentation(&wrapped).summary, "ls");
1775
1776 let malformed = ToolCall::new("bad", "Bash")
1777 .kind(ToolKind::Execute)
1778 .raw_input(json!({"command": "cd && ("}));
1779 assert_eq!(tool_call_presentation(&malformed).summary, "Bash");
1780 }
1781
1782 #[test]
1783 fn explicit_empty_raw_input_drops_a_stale_raw_summary() {
1784 let initial = ToolCall::new("call", "Bash")
1785 .kind(ToolKind::Execute)
1786 .raw_input(json!({"command": "python script.py"}));
1787 let previous = tool_call_presentation(&initial);
1788 let empty_input = json!({"command": null});
1789 let updated = update_tool_call_presentation(
1790 Some(&previous),
1791 "Running: ls -la",
1792 None,
1793 Some(&empty_input),
1794 None,
1795 );
1796 assert_eq!(updated.summary, "ls");
1797 assert_eq!(updated.source_kind, ToolSummarySourceKind::Title);
1798
1799 let output = json!({"command": "cat result.txt"});
1800 let updated = update_tool_call_presentation(
1801 Some(&previous),
1802 "Running: ls -la",
1803 None,
1804 Some(&empty_input),
1805 Some(&output),
1806 );
1807 assert_eq!(updated.summary, "cat");
1808 assert_eq!(updated.source_kind, ToolSummarySourceKind::RawOutput);
1809
1810 let output_initial = ToolCall::new("output", "Bash")
1811 .kind(ToolKind::Execute)
1812 .raw_output(json!({"command": "python result.py"}));
1813 let output_previous = tool_call_presentation(&output_initial);
1814 let empty_output = json!({"command": null});
1815 let updated = update_tool_call_presentation(
1816 Some(&output_previous),
1817 "Running: ls -la",
1818 None,
1819 None,
1820 Some(&empty_output),
1821 );
1822 assert_eq!(updated.summary, "ls");
1823 assert_eq!(updated.source_kind, ToolSummarySourceKind::Title);
1824 }
1825
1826 fn rendered_tool_row(call: &ToolCall) -> String {
1827 let presentation = tool_call_presentation(call);
1828 let item = TranscriptItem {
1829 stable_id: "tool:golden".into(),
1830 position: 1,
1831 latest_content_event_ordinal: None,
1832 created_at_ms: 1,
1833 last_changed_at_ms: 1,
1834 body: TranscriptBody::Tool {
1835 call: serde_json::to_value(call).expect("serialize tool call"),
1836 terminal_outputs: Vec::new(),
1837 terminal_refs: Vec::new(),
1838 presentation: Some(Box::new(presentation)),
1839 },
1840 };
1841 transcript_item_text(&item)
1842 }
1843
1844 fn append_tool_row(output: &mut String, label: &str, call: &ToolCall) {
1845 use std::fmt::Write as _;
1846 if !output.is_empty() {
1847 output.push('\n');
1848 }
1849 writeln!(output, "=== {label} (transcript row) ===").unwrap();
1850 output.push_str(&rendered_tool_row(call));
1851 output.push('\n');
1852 }
1853
1854 #[test]
1855 fn golden_transcript_tool_row_summary() {
1856 use std::fmt::Write as _;
1857
1858 let mut output = String::new();
1859 let diff = agent_client_protocol::schema::v1::Diff::new("/workspace/new.txt", "one\ntwo\n");
1860 writeln!(output, "=== new file diff detail (transcript row) ===").unwrap();
1861 writeln!(output, "{}", format_diffstat(&diff)).unwrap();
1862
1863 let terminal = |exit_code, signal: Option<&str>, truncated, text: &str| {
1864 let record = TerminalOutputRecord {
1865 terminal_id: "term-1".into(),
1866 output: text.into(),
1867 truncated,
1868 exit_code,
1869 signal: signal.map(str::to_owned),
1870 };
1871 let item = TranscriptItem {
1872 stable_id: "terminal:golden".into(),
1873 position: 1,
1874 latest_content_event_ordinal: None,
1875 created_at_ms: 1,
1876 last_changed_at_ms: 1,
1877 body: TranscriptBody::TerminalOutput { record },
1878 };
1879 transcript_item_text(&item)
1880 };
1881 for (label, row) in [
1882 ("successful terminal", terminal(Some(0), None, false, "out")),
1883 ("truncated terminal", terminal(Some(1), None, true, "out")),
1884 (
1885 "signal termination",
1886 terminal(None, Some("SIGKILL"), false, "out"),
1887 ),
1888 ("released terminal", terminal(None, None, false, "out")),
1889 (
1890 "silent signal termination",
1891 terminal(None, Some("SIGTERM"), false, ""),
1892 ),
1893 ] {
1894 writeln!(output, "\n=== {label} (transcript row) ===\n{row}").unwrap();
1895 }
1896
1897 append_tool_row(
1898 &mut output,
1899 "shell command",
1900 &ToolCall::new("shell", "Terminal")
1901 .kind(ToolKind::Execute)
1902 .raw_input(json!({
1903 "command": ["bash", "-lc", "cd dir && python x.py | cat"]
1904 })),
1905 );
1906 append_tool_row(
1907 &mut output,
1908 "ordinary argv command",
1909 &ToolCall::new("argv", "Execute")
1910 .kind(ToolKind::Execute)
1911 .raw_input(json!({"command": ["python", "-c", "print(1)"]})),
1912 );
1913
1914 let update_call = ToolCall::new("shell", "Bash")
1915 .kind(ToolKind::Execute)
1916 .raw_input(json!({"command": "cargo test"}));
1917 let original = tool_call_presentation(&update_call);
1918 let mut output_update = ToolCallUpdateFields::default();
1919 output_update.raw_output = Some(json!({"output": "x".repeat(128 * 1024)}));
1920 writeln!(output, "\n=== output-only update (transcript row) ===").unwrap();
1921 writeln!(
1922 output,
1923 "presentation changed: {}",
1924 tool_call_update_changes_presentation(&update_call, &output_update)
1925 )
1926 .unwrap();
1927 writeln!(output, "{}", original.summary).unwrap();
1928 let mut status_update = ToolCallUpdateFields::default();
1929 status_update.status = Some(ToolCallStatus::Completed);
1930 writeln!(
1931 output,
1932 "status-only update changes presentation: {}",
1933 tool_call_update_changes_presentation(&update_call, &status_update)
1934 )
1935 .unwrap();
1936 let output_call = ToolCall::new("output", "Bash").kind(ToolKind::Execute);
1937 writeln!(
1938 output,
1939 "output-only call changes presentation: {}",
1940 tool_call_update_changes_presentation(&output_call, &output_update)
1941 )
1942 .unwrap();
1943 let changed_input = json!({"command": "cargo check"});
1944 let mut input_update = ToolCallUpdateFields::default();
1945 input_update.raw_input = Some(changed_input.clone());
1946 writeln!(
1947 output,
1948 "changed command presentation: {}",
1949 tool_call_update_changes_presentation(&update_call, &input_update)
1950 )
1951 .unwrap();
1952 writeln!(
1953 output,
1954 "updated row: {}",
1955 update_tool_call_presentation(
1956 Some(&original),
1957 "Bash",
1958 Some(ToolKind::Execute),
1959 Some(&changed_input),
1960 None,
1961 )
1962 .summary
1963 )
1964 .unwrap();
1965
1966 for (label, command) in [
1967 (
1968 "git registered verb",
1969 json!({"command": ["git", "--no-pager", "status", "--short"]}),
1970 ),
1971 (
1972 "cargo registered verb",
1973 json!({"command": ["cargo", "+nightly", "test", "--package", "hel"]}),
1974 ),
1975 (
1976 "gh registered namespace",
1977 json!({"command": ["gh", "--hostname", "github.example", "pr", "list"]}),
1978 ),
1979 (
1980 "docker registered namespace",
1981 json!({"command": ["docker", "--context", "work", "compose", "up"]}),
1982 ),
1983 (
1984 "podman registered namespace",
1985 json!({"command": ["podman", "machine", "list"]}),
1986 ),
1987 (
1988 "uv registered namespace",
1989 json!({"command": ["uv", "--project", "app", "pip", "install", "ruff"]}),
1990 ),
1991 (
1992 "rustup registered namespace",
1993 json!({"command": ["rustup", "toolchain", "list"]}),
1994 ),
1995 (
1996 "npm registered namespace",
1997 json!({"command": ["npm", "--prefix", "web", "run", "build"]}),
1998 ),
1999 ] {
2000 append_tool_row(
2001 &mut output,
2002 label,
2003 &ToolCall::new("argv", "Bash")
2004 .kind(ToolKind::Execute)
2005 .raw_input(command),
2006 );
2007 }
2008
2009 let shell = ToolCall::new("string", "Bash")
2010 .kind(ToolKind::Execute)
2011 .raw_input(json!({"command": "git --no-pager status --short"}));
2012 let argv = ToolCall::new("argv", "Bash")
2013 .kind(ToolKind::Execute)
2014 .raw_input(json!({"command": ["git", "--no-pager", "status", "--short"]}));
2015 writeln!(
2016 output,
2017 "\n=== shell and argv invocation depth (transcript rows) ==="
2018 )
2019 .unwrap();
2020 writeln!(output, "shell: {}", rendered_tool_row(&shell)).unwrap();
2021 writeln!(output, "argv: {}", rendered_tool_row(&argv)).unwrap();
2022
2023 for (label, command) in [
2024 ("sudo wrapper", "sudo -n git status"),
2025 ("env wrapper", "env FOO=bar git status"),
2026 ("command wrapper", "command git status"),
2027 ] {
2028 append_tool_row(
2029 &mut output,
2030 label,
2031 &ToolCall::new("wrapper", "Bash")
2032 .kind(ToolKind::Execute)
2033 .raw_input(json!({"command": command})),
2034 );
2035 }
2036
2037 for (label, command) in [
2038 (
2039 "unlisted build tool",
2040 json!({"command": ["mytool", "build", "src"]}),
2041 ),
2042 (
2043 "unlisted script",
2044 json!({"command": ["mytool", "./script.sh"]}),
2045 ),
2046 ("python script", json!({"command": ["python", "script.py"]})),
2047 (
2048 "gh API namespace",
2049 json!({"command": ["gh", "api", "graphql"]}),
2050 ),
2051 (
2052 "docker run namespace",
2053 json!({"command": ["docker", "run", "ubuntu"]}),
2054 ),
2055 (
2056 "registered future git verb",
2057 json!({"command": ["git", "future-verb"]}),
2058 ),
2059 (
2060 "absolute git with attached directory",
2061 json!({"command": ["/usr/bin/git", "-Crepo", "status"]}),
2062 ),
2063 (
2064 "git short global option",
2065 json!({"command": ["git", "-c", "core.pager=cat", "status"]}),
2066 ),
2067 (
2068 "gh attached repository option",
2069 json!({"command": ["gh", "-Rorg/repo", "pr", "list"]}),
2070 ),
2071 (
2072 "cargo color option",
2073 json!({"command": ["cargo", "--color=always", "test"]}),
2074 ),
2075 (
2076 "cargo config option",
2077 json!({"command": ["cargo", "--config", "build.jobs=2", "test"]}),
2078 ),
2079 ] {
2080 append_tool_row(
2081 &mut output,
2082 label,
2083 &ToolCall::new("argv", "Bash")
2084 .kind(ToolKind::Execute)
2085 .raw_input(command),
2086 );
2087 }
2088
2089 for command in [
2090 vec!["nice", "-n", "10", "python3", "script.py"],
2091 vec!["nice", "--adjustment", "10", "python3", "script.py"],
2092 vec!["nice", "--adjustment=10", "python3", "script.py"],
2093 vec!["nice", "-10", "python3", "script.py"],
2094 vec!["nice", "-n10", "python3", "script.py"],
2095 vec!["nice", "--", "python3", "script.py"],
2096 ] {
2097 append_tool_row(
2098 &mut output,
2099 "nice command options",
2100 &ToolCall::new("nice", "Bash")
2101 .kind(ToolKind::Execute)
2102 .raw_input(json!({"command": command})),
2103 );
2104 }
2105 append_tool_row(
2106 &mut output,
2107 "nice shell command",
2108 &ToolCall::new("nice-shell", "Bash")
2109 .kind(ToolKind::Execute)
2110 .raw_input(json!({"command": "nice -n 10 python3 script.py"})),
2111 );
2112 append_tool_row(
2113 &mut output,
2114 "non-execute tool title",
2115 &ToolCall::new("read", "Read src/lib.rs").kind(ToolKind::Read),
2116 );
2117
2118 mj_core::golden::assert_golden(
2119 env!("CARGO_MANIFEST_DIR"),
2120 "transcript-tool-row-summary",
2121 &output,
2122 );
2123 }
2124}