vtcode_commons/ui_protocol/
tool_summary.rs1use 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}