Skip to main content

vtcode_commons/ui_protocol/
tool_summary.rs

1//! Data and grouping rules for compact tool transition summaries.
2
3use std::collections::BTreeMap;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct CompactToolSummaryLine {
7    pub kind: CompactToolSummaryLineKind,
8    pub text: String,
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum CompactToolSummaryLineKind {
13    Info,
14    Detail,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum CompactToolSummaryStatus {
19    Success,
20    Failure,
21    Warning,
22    Cancelled,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CompactToolSummaryCall {
27    pub canonical_tool_name: String,
28    pub semantic_action: String,
29    pub stable_arguments: String,
30    pub headline: String,
31    pub details: Vec<CompactToolSummaryDetail>,
32    pub output_boundary: bool,
33    pub status: CompactToolSummaryStatus,
34    pub expanded_lines: Vec<CompactToolSummaryLine>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct CompactToolSummaryGroup {
39    pub calls: Vec<CompactToolSummaryCall>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct CompactToolSummaryDetail {
44    pub label: String,
45    pub value: String,
46}
47
48pub const MAX_COMPACT_DISTINCT_VALUES: usize = 3;
49
50fn can_join(left: &CompactToolSummaryCall, right: &CompactToolSummaryCall) -> bool {
51    !left.output_boundary
52        && !right.output_boundary
53        && left.status == CompactToolSummaryStatus::Success
54        && right.status == CompactToolSummaryStatus::Success
55        && left.canonical_tool_name == right.canonical_tool_name
56        && left.semantic_action == right.semantic_action
57        && left.stable_arguments == right.stable_arguments
58}
59
60pub fn adjacent_compact_summary_groups(calls: Vec<CompactToolSummaryCall>) -> Vec<CompactToolSummaryGroup> {
61    let mut groups = Vec::new();
62    let mut current: Option<CompactToolSummaryGroup> = None;
63
64    for call in calls {
65        let joins = current
66            .as_ref()
67            .and_then(|group| group.calls.last())
68            .is_some_and(|last| can_join(last, &call));
69        if joins {
70            if let Some(group) = &mut current {
71                group.calls.push(call);
72            }
73        } else {
74            if let Some(group) = current.take() {
75                groups.push(group);
76            }
77            current = Some(CompactToolSummaryGroup { calls: vec![call] });
78        }
79    }
80    if let Some(group) = current {
81        groups.push(group);
82    }
83    groups
84}
85
86pub fn compact_detail_values(group: &CompactToolSummaryGroup) -> Vec<CompactToolSummaryDetail> {
87    let mut labels = Vec::new();
88    for call in &group.calls {
89        for detail in &call.details {
90            if !labels.contains(&detail.label) {
91                labels.push(detail.label.clone());
92            }
93        }
94    }
95
96    labels
97        .into_iter()
98        .map(|label| {
99            let mut values = Vec::new();
100            for call in &group.calls {
101                let value = call
102                    .details
103                    .iter()
104                    .find(|detail| detail.label == label)
105                    .map(|detail| detail.value.clone())
106                    .unwrap_or_else(|| "-".to_string());
107                if !values.contains(&value) {
108                    values.push(value);
109                }
110            }
111            if values.len() <= 1 {
112                return CompactToolSummaryDetail {
113                    label,
114                    value: values.into_iter().next().unwrap_or_default(),
115                };
116            }
117
118            let omitted = values.len().saturating_sub(MAX_COMPACT_DISTINCT_VALUES);
119            values.truncate(MAX_COMPACT_DISTINCT_VALUES);
120            let mut value = values.join(", ");
121            if omitted > 0 {
122                value.push_str(&format!(", +{omitted} more"));
123            }
124            CompactToolSummaryDetail { label, value }
125        })
126        .collect()
127}
128
129pub fn stable_arguments_json(value: &serde_json::Value) -> String {
130    const PAGINATION_ARGUMENTS: &[&str] = &["max_results", "limit", "offset", "page", "cursor"];
131
132    fn normalize(value: &serde_json::Value, root: bool) -> serde_json::Value {
133        match value {
134            serde_json::Value::Object(map) => {
135                let ordered = map
136                    .iter()
137                    .filter(|(key, _)| !root || !PAGINATION_ARGUMENTS.contains(&key.as_str()))
138                    .map(|(key, value)| (key.clone(), normalize(value, false)))
139                    .collect::<BTreeMap<_, _>>();
140                serde_json::Value::Object(ordered.into_iter().collect())
141            }
142            serde_json::Value::Array(items) => {
143                serde_json::Value::Array(items.iter().map(|item| normalize(item, false)).collect())
144            }
145            _ => value.clone(),
146        }
147    }
148
149    serde_json::to_string(&normalize(value, true)).unwrap_or_default()
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use serde_json::json;
156
157    fn call(stable_arguments: &str, limit: &str) -> CompactToolSummaryCall {
158        CompactToolSummaryCall {
159            canonical_tool_name: "code_search".to_string(),
160            semantic_action: "Search code".to_string(),
161            stable_arguments: stable_arguments.to_string(),
162            headline: "Search code".to_string(),
163            details: vec![CompactToolSummaryDetail {
164                label: "Max results".to_string(),
165                value: limit.to_string(),
166            }],
167            output_boundary: false,
168            status: CompactToolSummaryStatus::Success,
169            expanded_lines: Vec::new(),
170        }
171    }
172
173    #[test]
174    fn groups_identical_adjacent_calls() {
175        let groups = adjacent_compact_summary_groups(vec![call("{}", "30"), call("{}", "100")]);
176        assert_eq!(groups.len(), 1);
177        assert_eq!(groups[0].calls.len(), 2);
178        assert_eq!(compact_detail_values(&groups[0])[0].value, "30, 100");
179    }
180
181    #[test]
182    fn stable_argument_changes_split_groups() {
183        let groups =
184            adjacent_compact_summary_groups(vec![call("{\"path\":\"a\"}", "30"), call("{\"path\":\"b\"}", "30")]);
185        assert_eq!(groups.len(), 2);
186    }
187
188    #[test]
189    fn non_adjacent_compatible_calls_remain_separate() {
190        let mut other_tool = call("{}", "30");
191        other_tool.canonical_tool_name = "list_files".to_string();
192        let groups = adjacent_compact_summary_groups(vec![call("{}", "30"), other_tool, call("{}", "100")]);
193        assert_eq!(groups.len(), 3);
194        assert_eq!(groups[0].calls.len(), 1);
195        assert_eq!(groups[2].calls.len(), 1);
196    }
197
198    #[test]
199    fn output_boundaries_split_groups() {
200        let mut boundary = call("{}", "30");
201        boundary.output_boundary = true;
202        let groups = adjacent_compact_summary_groups(vec![call("{}", "30"), boundary, call("{}", "100")]);
203        assert_eq!(groups.len(), 3);
204    }
205
206    #[test]
207    fn failures_split_groups_and_remain_visible() {
208        let mut failure = call("{}", "30");
209        failure.status = CompactToolSummaryStatus::Failure;
210        let groups = adjacent_compact_summary_groups(vec![call("{}", "30"), failure, call("{}", "100")]);
211        assert_eq!(groups.len(), 3);
212        assert!(groups[1].calls[0].status == CompactToolSummaryStatus::Failure);
213    }
214
215    #[test]
216    fn warnings_and_cancellations_split_groups() {
217        for status in [CompactToolSummaryStatus::Warning, CompactToolSummaryStatus::Cancelled] {
218            let mut non_success = call("{}", "30");
219            non_success.status = status;
220            let groups = adjacent_compact_summary_groups(vec![call("{}", "30"), non_success, call("{}", "100")]);
221            assert_eq!(groups.len(), 3);
222            assert_eq!(groups[1].calls[0].status, status);
223        }
224    }
225
226    #[test]
227    fn distinct_detail_values_are_capped_deterministically() {
228        let calls = (0..5).map(|value| call("{}", &value.to_string())).collect::<Vec<_>>();
229        let groups = adjacent_compact_summary_groups(calls);
230        assert_eq!(compact_detail_values(&groups[0])[0].value, "0, 1, 2, +2 more");
231    }
232
233    #[test]
234    fn stable_arguments_ignore_pagination_only_values() {
235        let first = stable_arguments_json(&json!({"query": "foo", "max_results": 30, "cursor": "a"}));
236        let second = stable_arguments_json(&json!({"query": "foo", "max_results": 100, "cursor": "b"}));
237        assert_eq!(first, second);
238    }
239}