Skip to main content

mj_transcript/
transcript.rs

1//! Transcript projection and presentation implementation.
2
3use 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;
12/// Parser-rule version stored with cached tool summaries.
13pub const TOOL_SUMMARY_VERSION: u8 = 2;
14
15/// Reduce a tool call to what a reader still needs, once a verified checkpoint
16/// holds the whole of it.
17///
18/// Tool output is where a projection's bytes are: on one measured session,
19/// 561 MB of 635 MiB. Behind a checkpoint nothing reads it โ€” the checkpoint
20/// archive carries the complete transcript, and restoring it brings the output
21/// back โ€” so what stays here is what the transcript still shows: which tool
22/// ran, on what, with what result, and how many lines each edit changed.
23///
24/// Returns whether anything changed, so a caller can skip the write.
25pub 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    // Diffs stay, because the transcript still shows their stat. Their patch
52    // text does not, and neither do the two file copies an older record holds
53    // instead of a patch: `diff::drop_patch_text` turns those into the
54    // counts `format_diffstat` reads before dropping them.
55    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    // Round-trip through `ToolCallContent`, not `Diff`: the variant tag lives
67    // on the enum, and writing back a bare `Diff` would strip it and make the
68    // whole tool call unreadable.
69    let mut content = match serde_json::from_value::<ToolCallContent>(item.clone()) {
70        Ok(content) => content,
71        // Content this cannot read is content it must not rewrite.
72        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
104/// Whether a partial update changes inputs used to derive the tool summary.
105pub 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
126/// Compute the stable presentation metadata for one complete ACP call.
127pub 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
150/// Use cached presentation data when it was produced by current parser rules,
151/// otherwise rebuild it from the complete stored call. This lets parser fixes
152/// repair existing transcripts while current summaries remain cheap to load.
153pub 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
163/// Apply the presentation-relevant portion of a partial ACP update to cached
164/// metadata. ACP updates replace only fields that are present, so a title
165/// update must not erase a summary selected from an earlier raw command.
166pub 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        // A question tool (Claude's AskUserQuestion) is titled with the
317        // question; its first word alone ("Which") says nothing.
318        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    // Parentheses are meaningful only for subshells that contain a command we
415    // retained. Other punctuation, such as case arms and group delimiters,
416    // is structural and must not leak into the compact summary.
417    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            // Kimi-style agents send a terminal reference and no textual copy
947            // of the output, so the record hel captured is the only thing a
948            // reader ever sees. Until the terminal is reaped there is none.
949            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    // Grok-style agents name the terminal on a mid-flight update and then
968    // replace `content` wholesale without it, so the output hel captured has
969    // nothing in the final call pointing at it. Show it rather than lose it.
970    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            // Kimi sends the captured stdout as ordinary tool content and in
977            // its raw result. Keep the exit summary without printing those
978            // same bytes a second time in Raw mode.
979            details.push(terminal_exit_summary(record));
980        } else {
981            details.push(sanitize_terminal_text(&terminal_output_detail(record)));
982        }
983    }
984    details
985}
986
987/// The output codex reports for a terminal it ran itself. Codex names its own
988/// server-side terminal, which hel never opened and has no record for, and
989/// puts the text in `rawOutput`; reading it here keeps such a call from
990/// rendering as a bare terminal id.
991fn 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
1006/// One terminal's output followed by how it ended.
1007pub 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
1015/// How a terminal ended, in one line.
1016fn 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
1048/// Append streamed agent or thought text to the transcript, merging it into
1049/// the entry it continues so a message arrives as one entry rather than one
1050/// per chunk.
1051pub(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
1085/// Apply the transcript-visible part of one ACP session update. Returns the
1086/// update again when it changes the session surface rather than the
1087/// transcript, so the chat view handles those without decoding twice.
1088pub 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        // PromptAccepted is the canonical local user-message event. ACP
1122        // user chunks would duplicate it during replay.
1123        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
1203/// Apply the transcript-visible part of one persisted runtime event. Returns
1204/// the event again when it only configures the session surface, which is the
1205/// chat view's business rather than the transcript's.
1206pub 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
1253/// One transcript item flattened to the text a reader would see.
1254///
1255/// A caller that wants the structure reads the body itself; this is the plain
1256/// reading, built from the same flatteners every other surface uses so that a
1257/// tool call reads as the command it ran rather than as JSON.
1258pub 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    // A diff recorded since `diff` landed already carries its counts, so
1347    // this is a lookup. An older record still holds both file copies and is
1348    // diffed here on demand.
1349    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    /// I1-18: Claude's question tool is titled with the question itself. Its
1365    /// row showed only "Which"; it must show the whole question.
1366    #[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        // A terminal that produced nothing is still worth a line: the summary
1425        // is all a reader has to go on.
1426        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(&quoted).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(&current), &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}