1use std::borrow::Cow;
9
10use serde_json::Value;
11
12use crate::config::constants::tools;
13use crate::tools::tool_intent;
14
15pub fn reduce_tool_result(tool_name: &str, result: Value) -> Value {
19 let canonical_tool_name = tool_intent::canonical_command_session_tool_name(tool_name).unwrap_or(tool_name);
20 match canonical_tool_name {
21 tools::READ_FILE => reduce_read_file_result(result),
22 tools::UNIFIED_EXEC => reduce_command_result(result),
23 _ => result,
24 }
25}
26
27pub fn strip_tui_display_fields<'a>(tool_name: &str, value: &'a Value) -> Cow<'a, Value> {
45 let canonical = tool_intent::canonical_command_session_tool_name(tool_name).unwrap_or(tool_name);
46 if canonical != tools::TASK_TRACKER && canonical != tools::MATRIX {
47 return Cow::Borrowed(value);
48 }
49 let Some(obj) = value.as_object() else {
50 return Cow::Borrowed(value);
51 };
52 if !obj.contains_key("view") {
53 return Cow::Borrowed(value);
54 }
55 let mut stripped = obj.clone();
56 stripped.remove("view");
57 Cow::Owned(Value::Object(stripped))
58}
59
60pub fn project_model_tool_result<'a>(tool_name: &str, args: &Value, value: &'a Value) -> Cow<'a, Value> {
63 let mut projected = strip_tui_display_fields(tool_name, value);
64 if tool_name != tools::TASK_TRACKER
65 || args.get("action").and_then(Value::as_str) != Some("update")
66 || args.get("items").is_some()
67 {
68 return projected;
69 }
70 let index_path = args
71 .get("index_path")
72 .and_then(Value::as_str)
73 .map(str::to_owned)
74 .or_else(|| args.get("index").and_then(Value::as_u64).map(|index| index.to_string()));
75 let Some(index_path) = index_path else {
76 return projected;
77 };
78 let Some(changed_item) = value
79 .get("checklist")
80 .and_then(|checklist| checklist.get("items"))
81 .and_then(Value::as_array)
82 .and_then(|items| {
83 items.iter().find(|item| {
84 item.get("index_path")
85 .and_then(Value::as_str)
86 .is_some_and(|path| path == index_path)
87 || item
88 .get("index")
89 .and_then(Value::as_u64)
90 .is_some_and(|index| index.to_string() == index_path)
91 })
92 })
93 .cloned()
94 else {
95 return projected;
96 };
97 if let Some(object) = projected.to_mut().as_object_mut() {
98 if let Some(checklist) = object.get_mut("checklist").and_then(Value::as_object_mut) {
99 checklist.remove("items");
100 }
101 object.insert("changed_item".to_owned(), changed_item);
102 }
103 projected
104}
105
106const MAX_RESULT_BYTES: usize = 32 * 1024;
110
111fn truncate_utf8_bytes(text: &str, max_bytes: usize) -> (String, bool) {
112 if text.len() <= max_bytes {
113 return (text.to_string(), false);
114 }
115 (vtcode_commons::formatting::truncate_utf8_prefix(text, max_bytes).to_string(), true)
116}
117
118fn reduce_read_file_result(result: Value) -> Value {
119 const MAX_FILE_LINES: usize = 2000;
120
121 let Some(obj) = result.as_object() else {
122 return result;
123 };
124 let Some(content) = obj.get("content").and_then(Value::as_str) else {
125 return result;
126 };
127
128 let (line_truncated, content) = match truncate_lines(content, MAX_FILE_LINES) {
129 Some((truncated, _)) => (true, truncated),
130 None => (false, content.to_string()),
131 };
132 let (content, byte_truncated) = truncate_utf8_bytes(&content, MAX_RESULT_BYTES);
133 let is_truncated = line_truncated || byte_truncated;
134 if !is_truncated {
135 return result;
139 }
140
141 let mut reduced = obj.clone();
142 reduced.insert("content".to_string(), Value::String(content));
143 reduced.insert("is_truncated".to_string(), Value::Bool(true));
144 if reduced.contains_key("next_read_args") || reduced.get("has_more").and_then(Value::as_bool) == Some(true) {
148 reduced.insert("chunk_tail_omitted".to_string(), Value::Bool(true));
149 }
150 if line_truncated {
151 reduced.insert("note".to_string(), Value::String("File content truncated for context economy.".to_string()));
152 }
153
154 Value::Object(reduced)
155}
156
157fn reduce_command_result(result: Value) -> Value {
158 const MAX_FILE_LINES: usize = 2000;
159
160 let Some(obj) = result.as_object() else {
161 return result;
162 };
163 let stream_key = if obj.get("stdout").and_then(Value::as_str).is_some() {
164 "stdout"
165 } else {
166 "output"
167 };
168 let Some(stream) = obj.get(stream_key).and_then(Value::as_str) else {
169 return result;
170 };
171
172 let (line_truncated, lines_count, stream) = match truncate_lines(stream, MAX_FILE_LINES) {
173 Some((truncated, lines_count)) => (true, lines_count, truncated),
174 None => (false, 0, stream.to_string()),
175 };
176 let (stream, byte_truncated) = truncate_utf8_bytes(&stream, MAX_RESULT_BYTES);
177 if !line_truncated && !byte_truncated {
178 return result;
179 }
180
181 let mut reduced = obj.clone();
182 reduced.insert(stream_key.to_string(), Value::String(stream));
183 reduced.insert("is_truncated".to_string(), Value::Bool(true));
184 if line_truncated {
185 reduced.insert("original_lines".to_string(), Value::Number(serde_json::Number::from(lines_count as u64)));
186 }
187 reduced.insert("note".to_string(), Value::String("Command output truncated for context economy.".to_string()));
188 Value::Object(reduced)
189}
190
191pub fn truncate_lines(text: &str, max_lines: usize) -> Option<(String, usize)> {
192 if max_lines == 0 {
193 return Some((String::new(), text.lines().count()));
194 }
195
196 let mut lines = text.lines();
197 let mut total = 0usize;
198 let mut out = String::new();
199 while let Some(line) = lines.next() {
200 total += 1;
201 if total <= max_lines {
202 if total > 1 {
203 out.push('\n');
204 }
205 out.push_str(line);
206 continue;
207 }
208 total += lines.count();
209 return Some((out, total));
210 }
211 None
212}
213
214#[cfg(test)]
215mod tests {
216 #[test]
217 fn tracker_projection_keeps_only_the_changed_hierarchical_item() {
218 let original = serde_json::json!({
219 "status": "updated",
220 "checklist": {"total": 2, "completed": 1, "items": [
221 {"index_path": "1", "description": "Parent", "status": "pending"},
222 {"index_path": "1.1", "description": "Child", "status": "completed", "verify": ["cargo check"]}
223 ]},
224 "view": {"lines": ["Parent", "Child"]}
225 });
226 let projected = project_model_tool_result(
227 "task_tracker",
228 &serde_json::json!({"action": "update", "index_path": "1.1"}),
229 &original,
230 );
231 assert_eq!(projected["changed_item"]["description"], "Child");
232 assert_eq!(projected["changed_item"]["verify"], serde_json::json!(["cargo check"]));
233 assert_eq!(projected["checklist"]["total"], 2);
234 assert!(projected["checklist"].get("items").is_none());
235 assert!(projected.get("view").is_none());
236 assert_eq!(original["checklist"]["items"].as_array().unwrap().len(), 2);
237 assert!(original.get("view").is_some());
238 assert!(projected.to_string().len() < original.to_string().len());
239 for args in [
240 serde_json::json!({"action": "list"}),
241 serde_json::json!({"action": "create"}),
242 serde_json::json!({"action": "update", "items": ["Replacement"]}),
243 serde_json::json!({"action": "update", "index_path": "9"}),
244 ] {
245 let projected = project_model_tool_result("task_tracker", &args, &original);
246 assert_eq!(projected["checklist"]["items"], original["checklist"]["items"]);
247 assert!(projected.get("changed_item").is_none());
248 }
249 }
250
251 #[test]
252 fn tracker_projection_supports_standard_indices() {
253 let result = serde_json::json!({"checklist": {"items": [
254 {"index": 1, "description": "First"}, {"index": 2, "description": "Second"}
255 ]}});
256 let projected =
257 project_model_tool_result("task_tracker", &serde_json::json!({"action": "update", "index": 2}), &result);
258 assert_eq!(projected["changed_item"]["description"], "Second");
259 assert_eq!(project_model_tool_result("exec_command", &serde_json::json!({}), &result).as_ref(), &result);
260 }
261 use super::*;
262 use serde_json::json;
263
264 #[test]
265 fn strip_view_removes_view_from_task_tracker_result() {
266 let result = json!({
267 "status": "updated",
268 "message": "Item 1 status changed: pending → completed",
269 "checklist": { "title": "Demo", "total": 2, "completed": 1, "items": [] },
270 "view": { "title": "Demo", "lines": [{ "display": "└ [x] step one" }] },
271 });
272 let stripped = strip_tui_display_fields("task_tracker", &result);
273 assert!(stripped.get("view").is_none(), "view should be removed");
274 assert!(stripped.get("checklist").is_some(), "checklist must remain for the model");
275 assert_eq!(stripped["status"], "updated");
276 }
277
278 #[test]
279 fn strip_view_borrows_non_tracker_tools_unchanged() {
280 let result = json!({ "status": "ok", "output": "hello" });
281 let stripped = strip_tui_display_fields("exec_command", &result);
282 assert!(matches!(stripped, Cow::Borrowed(_)), "non-tracker tools should borrow without allocation");
283 assert_eq!(stripped.as_ref(), &result);
284 }
285
286 #[test]
287 fn strip_view_borrows_tracker_result_without_view_field() {
288 let result = json!({ "status": "empty", "message": "No active checklist." });
289 let stripped = strip_tui_display_fields("task_tracker", &result);
290 assert!(
291 matches!(stripped, Cow::Borrowed(_)),
292 "tracker results without `view` should borrow without allocation"
293 );
294 assert_eq!(stripped.as_ref(), &result);
295 }
296
297 #[test]
298 fn strip_view_preserves_checklist_items_and_metadata() {
299 let result = json!({
300 "status": "ok",
301 "checklist": {
302 "title": "Plan",
303 "total": 2,
304 "completed": 0,
305 "items": [
306 { "index": 1, "description": "Step A", "status": "pending", "files": ["a.rs"], "outcome": null, "verify": ["cargo check"] },
307 { "index": 2, "description": "Step B", "status": "pending" },
308 ],
309 },
310 "view": { "title": "Plan", "lines": [{ "display": "├ [ ] Step A" }] },
311 });
312 let stripped = strip_tui_display_fields("task_tracker", &result);
313 let items = stripped["checklist"]["items"].as_array().unwrap();
314 assert_eq!(items.len(), 2);
315 assert_eq!(items[0]["verify"][0], "cargo check");
316 assert_eq!(items[1]["description"], "Step B");
317 assert!(stripped.get("view").is_none());
318 }
319
320 #[test]
321 fn strip_view_handles_non_object_result() {
322 let result = json!("not an object");
323 let stripped = strip_tui_display_fields("task_tracker", &result);
324 assert_eq!(stripped.as_ref(), &result);
325 }
326
327 #[test]
328 fn reduce_read_file_preserves_continuation_fields_when_not_truncated() {
329 let result = json!({
330 "success": true,
331 "content": "short body",
332 "path": "src/lib.rs",
333 "has_more": true,
334 "next_read_args": { "path": "src/lib.rs", "offset": 80 },
335 "spool_path": ".vtcode/context/tool_outputs/x",
336 "spooled_bytes": 999,
337 });
338 let reduced = reduce_tool_result("read_file", result.clone());
339 assert_eq!(reduced, result, "untruncated read results must stay intact for paging");
340 }
341
342 #[test]
343 fn reduce_read_file_truncates_large_content_but_keeps_next_read_args() {
344 let content = "a\n".repeat(2_500);
345 let result = json!({
346 "success": true,
347 "content": content,
348 "path": "src/lib.rs",
349 "has_more": true,
350 "next_read_args": { "path": "src/lib.rs", "offset": 2500 },
351 });
352 let reduced = reduce_tool_result("read_file", result);
353 assert_eq!(reduced["is_truncated"], json!(true));
354 assert!(reduced["content"].as_str().unwrap().lines().count() < 2_500, "content must be line-capped");
355 assert_eq!(reduced["has_more"], json!(true), "paging fields survive truncation");
356 assert!(reduced.get("next_read_args").is_some(), "next_read_args must survive truncation");
357 assert_eq!(
358 reduced["chunk_tail_omitted"],
359 json!(true),
360 "byte-capped page must warn that the chunk tail is not in content"
361 );
362 }
363}