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