oxicode_vtui_compat/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(
61 calls: Vec<CompactToolSummaryCall>,
62) -> Vec<CompactToolSummaryGroup> {
63 let mut groups = Vec::new();
64 let mut current: Option<CompactToolSummaryGroup> = None;
65
66 for call in calls {
67 let joins = current
68 .as_ref()
69 .and_then(|group| group.calls.last())
70 .is_some_and(|last| can_join(last, &call));
71 if joins {
72 if let Some(group) = &mut current {
73 group.calls.push(call);
74 }
75 } else {
76 if let Some(group) = current.take() {
77 groups.push(group);
78 }
79 current = Some(CompactToolSummaryGroup { calls: vec![call] });
80 }
81 }
82 if let Some(group) = current {
83 groups.push(group);
84 }
85 groups
86}
87
88pub fn compact_detail_values(group: &CompactToolSummaryGroup) -> Vec<CompactToolSummaryDetail> {
89 let mut labels = Vec::new();
90 for call in &group.calls {
91 for detail in &call.details {
92 if !labels.contains(&detail.label) {
93 labels.push(detail.label.clone());
94 }
95 }
96 }
97
98 labels
99 .into_iter()
100 .map(|label| {
101 let mut values = Vec::new();
102 for call in &group.calls {
103 let value = call
104 .details
105 .iter()
106 .find(|detail| detail.label == label)
107 .map(|detail| detail.value.clone())
108 .unwrap_or_else(|| "-".to_string());
109 if !values.contains(&value) {
110 values.push(value);
111 }
112 }
113 if values.len() <= 1 {
114 return CompactToolSummaryDetail {
115 label,
116 value: values.into_iter().next().unwrap_or_default(),
117 };
118 }
119
120 let omitted = values.len().saturating_sub(MAX_COMPACT_DISTINCT_VALUES);
121 values.truncate(MAX_COMPACT_DISTINCT_VALUES);
122 let mut value = values.join(", ");
123 if omitted > 0 {
124 value.push_str(&format!(", +{omitted} more"));
125 }
126 CompactToolSummaryDetail { label, value }
127 })
128 .collect()
129}
130
131pub fn stable_arguments_json(value: &serde_json::Value) -> String {
132 const PAGINATION_ARGUMENTS: &[&str] = &["max_results", "limit", "offset", "page", "cursor"];
133
134 fn normalize(value: &serde_json::Value, root: bool) -> serde_json::Value {
135 match value {
136 serde_json::Value::Object(map) => {
137 let ordered = map
138 .iter()
139 .filter(|(key, _)| !root || !PAGINATION_ARGUMENTS.contains(&key.as_str()))
140 .map(|(key, value)| (key.clone(), normalize(value, false)))
141 .collect::<BTreeMap<_, _>>();
142 serde_json::Value::Object(ordered.into_iter().collect())
143 }
144 serde_json::Value::Array(items) => {
145 serde_json::Value::Array(items.iter().map(|item| normalize(item, false)).collect())
146 }
147 _ => value.clone(),
148 }
149 }
150
151 serde_json::to_string(&normalize(value, true)).unwrap_or_default()
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use serde_json::json;
158
159 fn call(stable_arguments: &str, limit: &str) -> CompactToolSummaryCall {
160 CompactToolSummaryCall {
161 canonical_tool_name: "code_search".to_string(),
162 semantic_action: "Search code".to_string(),
163 stable_arguments: stable_arguments.to_string(),
164 headline: "Search code".to_string(),
165 details: vec![CompactToolSummaryDetail {
166 label: "Max results".to_string(),
167 value: limit.to_string(),
168 }],
169 output_boundary: false,
170 status: CompactToolSummaryStatus::Success,
171 expanded_lines: Vec::new(),
172 }
173 }
174
175 #[test]
176 fn groups_identical_adjacent_calls() {
177 let groups = adjacent_compact_summary_groups(vec![call("{}", "30"), call("{}", "100")]);
178 assert_eq!(groups.len(), 1);
179 assert_eq!(groups[0].calls.len(), 2);
180 assert_eq!(compact_detail_values(&groups[0])[0].value, "30, 100");
181 }
182
183 #[test]
184 fn stable_argument_changes_split_groups() {
185 let groups = adjacent_compact_summary_groups(vec![
186 call("{\"path\":\"a\"}", "30"),
187 call("{\"path\":\"b\"}", "30"),
188 ]);
189 assert_eq!(groups.len(), 2);
190 }
191
192 #[test]
193 fn non_adjacent_compatible_calls_remain_separate() {
194 let mut other_tool = call("{}", "30");
195 other_tool.canonical_tool_name = "list_files".to_string();
196 let groups =
197 adjacent_compact_summary_groups(vec![call("{}", "30"), other_tool, call("{}", "100")]);
198 assert_eq!(groups.len(), 3);
199 assert_eq!(groups[0].calls.len(), 1);
200 assert_eq!(groups[2].calls.len(), 1);
201 }
202
203 #[test]
204 fn output_boundaries_split_groups() {
205 let mut boundary = call("{}", "30");
206 boundary.output_boundary = true;
207 let groups =
208 adjacent_compact_summary_groups(vec![call("{}", "30"), boundary, call("{}", "100")]);
209 assert_eq!(groups.len(), 3);
210 }
211
212 #[test]
213 fn failures_split_groups_and_remain_visible() {
214 let mut failure = call("{}", "30");
215 failure.status = CompactToolSummaryStatus::Failure;
216 let groups =
217 adjacent_compact_summary_groups(vec![call("{}", "30"), failure, call("{}", "100")]);
218 assert_eq!(groups.len(), 3);
219 assert!(groups[1].calls[0].status == CompactToolSummaryStatus::Failure);
220 }
221
222 #[test]
223 fn warnings_and_cancellations_split_groups() {
224 for status in [
225 CompactToolSummaryStatus::Warning,
226 CompactToolSummaryStatus::Cancelled,
227 ] {
228 let mut non_success = call("{}", "30");
229 non_success.status = status;
230 let groups = adjacent_compact_summary_groups(vec![
231 call("{}", "30"),
232 non_success,
233 call("{}", "100"),
234 ]);
235 assert_eq!(groups.len(), 3);
236 assert_eq!(groups[1].calls[0].status, status);
237 }
238 }
239
240 #[test]
241 fn distinct_detail_values_are_capped_deterministically() {
242 let calls = (0..5)
243 .map(|value| call("{}", &value.to_string()))
244 .collect::<Vec<_>>();
245 let groups = adjacent_compact_summary_groups(calls);
246 assert_eq!(
247 compact_detail_values(&groups[0])[0].value,
248 "0, 1, 2, +2 more"
249 );
250 }
251
252 #[test]
253 fn stable_arguments_ignore_pagination_only_values() {
254 let first =
255 stable_arguments_json(&json!({"query": "foo", "max_results": 30, "cursor": "a"}));
256 let second =
257 stable_arguments_json(&json!({"query": "foo", "max_results": 100, "cursor": "b"}));
258 assert_eq!(first, second);
259 }
260}