1use crate::exec::events::{
2 AgentMessageItem, ErrorItem, ItemCompletedEvent, ItemStartedEvent, ItemUpdatedEvent, ReasoningItem, ThreadEvent,
3 ThreadItem, ThreadItemDetails, ToolCallStatus, ToolInvocationItem, ToolOutcome, ToolOutputItem,
4 tool_outcome_from_status,
5};
6use crate::tools::file_ops::{canonical_diff_previews, diff_preview_user_message};
7use serde_json::Value;
8use std::collections::HashMap;
9use std::fmt::Write;
10
11#[derive(Debug, Clone, Default)]
12struct StreamingTextState {
13 item_id: Option<String>,
14 text: String,
15 started: bool,
16}
17
18#[derive(Debug, Clone)]
19struct ToolCallStreamState {
20 item_id: String,
21 name: Option<String>,
22 arguments: String,
23 started: bool,
24 last_emitted_args_len: usize,
27 update_events: usize,
30}
31
32const MIN_TOOL_ARG_UPDATE_BYTES: usize = 512;
39
40const MAX_TOOL_ARG_UPDATE_EVENTS: usize = 8;
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ToolOutputPayload {
48 pub aggregated_output: String,
50 pub spool_path: Option<String>,
52}
53
54fn pluralize<'a>(count: u64, singular: &'a str, plural: &'a str) -> &'a str {
55 if count == 1 { singular } else { plural }
56}
57
58pub fn file_change_completed_event(
60 call_id: &str,
61 tool_name: &str,
62 args: &Value,
63 output: &Value,
64) -> Option<ThreadEvent> {
65 use crate::exec::events::{FileChangeItem, FileUpdateChange, PatchApplyStatus, PatchChangeKind};
66 let file_tool =
67 matches!(tool_name, "apply_patch" | "write_file" | "edit_file" | "create_file" | "delete_file" | "file")
68 || tool_name == crate::config::constants::tools::UNIFIED_FILE;
69 if !file_tool
70 || !crate::tools::tool_intent::classify_tool_intent(tool_name, args).mutating
71 || output.get("success").and_then(Value::as_bool) == Some(false)
72 || output.get("skipped").and_then(Value::as_bool) == Some(true)
73 || output.get("conflict").and_then(Value::as_bool) == Some(true)
74 || crate::tools::file_ops::diff_output_has_effective_change(output) == Some(false)
75 {
76 return None;
77 }
78 let previews = canonical_diff_previews(output);
79 let mut changes = Vec::new();
80 let mut diffs = Vec::new();
81 let mut additions = 0u64;
82 let mut deletions = 0u64;
83 let mut counts_available = true;
84 let mut diff_incomplete = previews.is_empty();
85 for preview in &previews {
86 if preview.get("is_empty").and_then(Value::as_bool) == Some(true)
87 && !matches!(preview.get("operation").and_then(Value::as_str), Some("created" | "deleted"))
88 {
89 continue;
90 }
91 let Some(path) = preview.get("path").and_then(Value::as_str) else {
92 continue;
93 };
94 let kind = match preview.get("operation").and_then(Value::as_str) {
95 Some("created" | "added") => PatchChangeKind::Add,
96 Some("deleted" | "removed") => PatchChangeKind::Delete,
97 _ => PatchChangeKind::Update,
98 };
99 changes.push(FileUpdateChange { path: path.to_owned(), kind });
100 diff_incomplete |= preview.get("truncated").and_then(Value::as_bool) == Some(true)
101 || preview.get("skipped").and_then(Value::as_bool) == Some(true)
102 || preview.get("content").and_then(Value::as_str).is_none();
103 if let Some(content) = preview.get("content").and_then(Value::as_str) {
104 diffs.push(content.to_owned());
105 }
106 if let (Some(a), Some(d)) = (
107 preview
108 .get("additions")
109 .or_else(|| preview.get("summary").and_then(|s| s.get("additions")))
110 .and_then(Value::as_u64),
111 preview
112 .get("deletions")
113 .or_else(|| preview.get("summary").and_then(|s| s.get("deletions")))
114 .and_then(Value::as_u64),
115 ) {
116 additions = additions.saturating_add(a);
117 deletions = deletions.saturating_add(d);
118 } else {
119 counts_available = false;
120 }
121 }
122 if changes.is_empty()
123 && let Some(paths) = output.get("modified_files").and_then(Value::as_array)
124 {
125 counts_available = false;
126 for path in paths.iter().filter_map(Value::as_str) {
127 changes.push(FileUpdateChange {
128 path: path.to_owned(),
129 kind: PatchChangeKind::Update,
130 });
131 }
132 }
133 if changes.is_empty() {
134 return None;
135 }
136 Some(ThreadEvent::ItemCompleted(ItemCompletedEvent {
137 item: ThreadItem {
138 id: format!("{call_id}:changes"),
139 context: None,
140 details: ThreadItemDetails::FileChange(Box::new(FileChangeItem {
141 diff_incomplete: diff_incomplete.then_some(true),
142 changes,
143 status: PatchApplyStatus::Completed,
144 unified_diff: (!diffs.is_empty()).then(|| diffs.join("\n")),
145 additions: counts_available.then_some(additions),
146 deletions: counts_available.then_some(deletions),
147 })),
148 },
149 }))
150}
151
152fn trimmed_string_field<'a>(output: &'a Value, key: &str) -> Option<&'a str> {
153 output
154 .get(key)
155 .and_then(Value::as_str)
156 .map(str::trim)
157 .filter(|text| !text.is_empty())
158}
159
160fn diff_preview_output(output: &Value) -> Option<String> {
161 let diffs = canonical_diff_previews(output);
162 if diffs.is_empty() {
163 return None;
164 }
165 let mut sections = Vec::new();
166
167 for diff in &diffs {
168 let path = diff.get("path").and_then(Value::as_str).unwrap_or("file");
169 let operation = diff
170 .get("operation")
171 .and_then(Value::as_str)
172 .map(|operation| format!(" ({operation})"))
173 .unwrap_or_default();
174 let counts =
175 match (diff.get("additions").and_then(Value::as_u64), diff.get("deletions").and_then(Value::as_u64)) {
176 (Some(additions), Some(deletions)) if additions > 0 || deletions > 0 => {
177 format!(" (+{additions} -{deletions})")
178 }
179 _ => String::new(),
180 };
181 if let Some(content) = diff
182 .get("content")
183 .and_then(Value::as_str)
184 .map(str::trim)
185 .filter(|content| !content.is_empty())
186 {
187 sections.push(format!("diff preview for {path}{operation}{counts}:\n{content}"));
188 continue;
189 }
190
191 if diff.get("skipped").and_then(Value::as_bool) == Some(true) {
192 let reason = diff_preview_user_message(diff);
193 sections.push(format!("diff preview for {path}{operation}{counts}: {reason}"));
194 continue;
195 }
196
197 if diff.get("is_empty").and_then(Value::as_bool) == Some(true) {
198 sections.push(format!("diff preview for {path}{operation}: no changes"));
199 }
200 }
201
202 (!sections.is_empty()).then(|| sections.join("\n"))
203}
204
205#[cold]
206fn trimmed_error_message(output: &Value) -> Option<&str> {
207 match output.get("error") {
208 Some(Value::String(message)) => Some(message.as_str()),
209 Some(Value::Object(error)) => error.get("message").and_then(Value::as_str),
210 _ => None,
211 }
212 .map(str::trim)
213 .filter(|text| !text.is_empty())
214}
215
216fn sample_strings_from_objects(items: &[Value], keys: &[&str], limit: usize) -> Vec<String> {
217 let mut samples = Vec::new();
218
219 for item in items {
220 let Some(value) = keys
221 .iter()
222 .find_map(|key| item.get(*key).and_then(Value::as_str))
223 .map(str::trim)
224 .filter(|text| !text.is_empty())
225 else {
226 continue;
227 };
228
229 if samples.iter().any(|sample| sample == value) {
230 continue;
231 }
232
233 samples.push(value.to_string());
234 if samples.len() >= limit {
235 break;
236 }
237 }
238
239 samples
240}
241
242fn match_path_text(item: &Value) -> Option<&str> {
243 item.get("path")
244 .and_then(Value::as_str)
245 .map(str::trim)
246 .filter(|text| !text.is_empty())
247 .or_else(|| {
248 item.get("file")
249 .and_then(Value::as_str)
250 .map(str::trim)
251 .filter(|text| !text.is_empty())
252 })
253 .or_else(|| {
254 item.get("data")
255 .and_then(Value::as_object)
256 .and_then(|data| data.get("path"))
257 .and_then(Value::as_object)
258 .and_then(|path| path.get("text"))
259 .and_then(Value::as_str)
260 .map(str::trim)
261 .filter(|text| !text.is_empty())
262 })
263}
264
265fn sample_match_paths(matches: &[Value], limit: usize) -> Vec<String> {
266 let mut samples = Vec::new();
267
268 for item in matches {
269 let Some(path) = match_path_text(item) else {
270 continue;
271 };
272
273 if samples.iter().any(|sample| sample == path) {
274 continue;
275 }
276
277 samples.push(path.to_string());
278 if samples.len() >= limit {
279 break;
280 }
281 }
282
283 samples
284}
285
286fn summarize_list_items(output: &Value, items: &[Value]) -> String {
287 let total = output
288 .get("total")
289 .or_else(|| output.get("count"))
290 .and_then(Value::as_u64)
291 .unwrap_or(items.len() as u64);
292
293 let (files, directories) =
294 items
295 .iter()
296 .fold((0u64, 0u64), |(files, directories), item| match item.get("type").and_then(Value::as_str) {
297 Some("file") => (files + 1, directories),
298 Some("directory") => (files, directories + 1),
299 _ => (files, directories),
300 });
301
302 let mut summary = format!("Listed {total} {}", pluralize(total, "item", "items"));
303 if files > 0 || directories > 0 {
304 let _ = write!(
305 summary,
306 " ({} {}, {} {})",
307 files,
308 pluralize(files, "file", "files"),
309 directories,
310 pluralize(directories, "directory", "directories"),
311 );
312 }
313
314 let samples = sample_strings_from_objects(items, &["path", "name"], 3);
315 if !samples.is_empty() {
316 let _ = write!(summary, ": {}", samples.join(", "));
317 }
318
319 summary
320}
321
322fn summarize_file_list(output: &Value, files: &[Value]) -> String {
323 let total = output.get("total").and_then(Value::as_u64).unwrap_or(files.len() as u64);
324 let mut summary = format!("Listed {total} {}", pluralize(total, "file", "files"));
325
326 let samples = files
327 .iter()
328 .filter_map(Value::as_str)
329 .map(str::trim)
330 .filter(|text| !text.is_empty())
331 .take(3)
332 .map(str::to_string)
333 .collect::<Vec<_>>();
334 if !samples.is_empty() {
335 let _ = write!(summary, ": {}", samples.join(", "));
336 }
337
338 summary
339}
340
341fn summarize_search_results(output: &Value, results: &[Value]) -> String {
342 let returned = output
343 .get("returned")
344 .or_else(|| output.get("total"))
345 .or_else(|| output.get("count"))
346 .and_then(Value::as_u64)
347 .unwrap_or(results.len() as u64);
348
349 let mut summary = if returned == 0 {
350 "No results found".to_string()
351 } else {
352 format!("Returned {returned} {}", pluralize(returned, "result", "results"))
353 };
354 if let Some(query) = trimmed_string_field(output, "query") {
355 let _ = write!(summary, " for `{query}`");
356 }
357
358 let mut samples = Vec::new();
361 for item in results {
362 let Some(path) = match_path_text(item) else { continue };
363 let sample = match item.get("line").and_then(Value::as_u64) {
364 Some(line) => format!("{path}:{line}"),
365 None => path.to_string(),
366 };
367 if samples.iter().any(|existing: &String| existing == &sample) {
368 continue;
369 }
370 samples.push(sample);
371 if samples.len() >= 3 {
372 break;
373 }
374 }
375 if !samples.is_empty() {
376 let _ = write!(summary, ": {}", samples.join(", "));
377 }
378
379 summary
380}
381
382fn summarize_matches(output: &Value, matches: &[Value]) -> String {
383 let total = output
384 .get("total_match_count")
385 .or_else(|| output.get("matched_count"))
386 .or_else(|| output.get("count"))
387 .and_then(Value::as_u64)
388 .unwrap_or(matches.len() as u64);
389
390 if total == 0 {
391 return "No matches found".to_string();
392 }
393
394 let mut summary = format!("Found {total} {}", pluralize(total, "match", "matches"));
395
396 let samples = sample_match_paths(matches, 3);
397 if !samples.is_empty() {
398 let _ = write!(summary, " in {}", samples.join(", "));
399 } else if let Some(path) = trimmed_string_field(output, "path") {
400 let _ = write!(summary, " in {path}");
401 }
402
403 summary
404}
405
406fn append_unique_line(lines: &mut Vec<String>, line: &str) {
407 if !lines.iter().any(|existing| existing == line) {
408 lines.push(line.to_string());
409 }
410}
411
412fn contains_line_block(container: &str, candidate: &str) -> bool {
413 !line_block_ranges(container, candidate).is_empty()
414}
415
416fn line_block_ranges(container: &str, candidate: &str) -> Vec<(usize, usize)> {
417 let container_lines = container.lines().collect::<Vec<_>>();
418 let candidate_lines = candidate.lines().collect::<Vec<_>>();
419 if candidate_lines.is_empty() || candidate_lines.len() > container_lines.len() {
420 return Vec::new();
421 }
422
423 container_lines
424 .windows(candidate_lines.len())
425 .enumerate()
426 .filter_map(|(start, window)| {
427 (window == candidate_lines.as_slice()).then_some((start, start + candidate_lines.len()))
428 })
429 .collect()
430}
431
432fn contains_distinct_line_blocks(container: &str, first: &str, second: &str) -> bool {
433 let first_ranges = line_block_ranges(container, first);
434 let second_ranges = line_block_ranges(container, second);
435 first_ranges.iter().any(|&(first_start, first_end)| {
436 second_ranges
437 .iter()
438 .any(|&(second_start, second_end)| first_end <= second_start || second_end <= first_start)
439 })
440}
441
442fn append_labeled_stream_text(lines: &mut Vec<String>, label: &str, text: &str) {
443 lines.push(format!("[{label}]\n{text}"));
444}
445
446fn append_unrepresented_stream_text(lines: &mut Vec<String>, text: &str) {
447 if !lines.iter().any(|existing| contains_line_block(existing, text)) {
448 lines.push(text.to_string());
449 }
450}
451
452fn aggregate_primary_streams(output: &Value) -> Vec<String> {
453 let merged = trimmed_string_field(output, "output");
454 let stdout = trimmed_string_field(output, "stdout");
455 let stderr = trimmed_string_field(output, "stderr");
456 let mut parts = Vec::new();
457
458 match (merged, stdout, stderr) {
459 (None, Some(stdout), Some(stderr)) => {
460 append_labeled_stream_text(&mut parts, "stdout", stdout);
464 append_labeled_stream_text(&mut parts, "stderr", stderr);
465 }
466 (None, Some(stdout), None) | (None, None, Some(stdout)) => parts.push(stdout.to_string()),
467 (None, None, None) => {}
468 (Some(merged), Some(stdout), Some(stderr)) if contains_distinct_line_blocks(merged, stdout, stderr) => {
469 parts.push(merged.to_string());
472 }
473 (Some(merged), Some(stdout), Some(stderr)) => {
474 let stdout_is_in_merged = contains_line_block(merged, stdout);
475 let stderr_is_in_merged = contains_line_block(merged, stderr);
476 let stdout_contains_merged = contains_line_block(stdout, merged);
477 let stderr_contains_merged = contains_line_block(stderr, merged);
478
479 if stdout_is_in_merged && !stderr_is_in_merged {
480 parts.push(merged.to_string());
481 append_labeled_stream_text(&mut parts, "stderr", stderr);
482 } else if stderr_is_in_merged && !stdout_is_in_merged {
483 parts.push(merged.to_string());
484 append_labeled_stream_text(&mut parts, "stdout", stdout);
485 } else {
486 append_labeled_stream_text(&mut parts, "stdout", stdout);
491 append_labeled_stream_text(&mut parts, "stderr", stderr);
492 if !stdout_contains_merged && !stderr_contains_merged {
493 append_unrepresented_stream_text(&mut parts, merged);
494 }
495 }
496 }
497 (Some(merged), Some(named), None) | (Some(merged), None, Some(named)) => {
498 if contains_line_block(merged, named) {
499 parts.push(merged.to_string());
502 } else if contains_line_block(named, merged) {
503 parts.push(named.to_string());
506 } else {
507 parts.push(merged.to_string());
508 append_unrepresented_stream_text(&mut parts, named);
509 }
510 }
511 (Some(merged), None, None) => parts.push(merged.to_string()),
512 }
513
514 if let Some(content) = trimmed_string_field(output, "content") {
515 append_unrepresented_stream_text(&mut parts, content);
518 }
519
520 parts
521}
522
523const STREAM_OUTPUT_METADATA_FIELDS: &[&str] = &[
524 "output",
525 "stdout",
526 "stderr",
527 "content",
528 "success",
529 "status",
530 "exit_code",
531 "command",
532 "working_directory",
533 "session_id",
534 "process_id",
535 "id",
536 "is_exited",
537 "rows",
538 "cols",
539 "wall_time",
540 "duration_ms",
541 "spool_path",
542 "critical_note",
543 "hint",
544 "message",
545 "next_action",
546 "error_message",
547];
548
549fn structured_stream_metadata(output: &Value, additional_excluded_fields: &[&str]) -> Option<String> {
550 let object = output.as_object()?;
551 let metadata = object
552 .iter()
553 .filter(|(key, value)| {
554 !(STREAM_OUTPUT_METADATA_FIELDS.contains(&key.as_str())
558 || additional_excluded_fields.contains(&key.as_str())
559 || matches!(value, Value::Null)
560 || (key.as_str() == "error" && value.is_string()))
561 })
562 .map(|(key, value)| (key.clone(), value.clone()))
563 .collect::<serde_json::Map<_, _>>();
564
565 if metadata.is_empty() {
566 return None;
567 }
568
569 serde_json::to_string_pretty(&Value::Object(metadata))
570 .ok()
571 .map(|metadata| format!("Structured output:\n{metadata}"))
572}
573
574fn append_primary_output_context_with_exclusions(
575 parts: &mut Vec<String>,
576 output: &Value,
577 additional_excluded_fields: &[&str],
578) {
579 if let Some(metadata) = structured_stream_metadata(output, additional_excluded_fields) {
580 append_unique_line(parts, &metadata);
581 }
582 if let Some(text) = trimmed_error_message(output) {
583 append_unique_line(parts, text);
584 }
585 for key in ["message", "critical_note", "hint", "next_action", "error_message"] {
586 if let Some(text) = trimmed_string_field(output, key) {
587 append_unique_line(parts, text);
588 }
589 }
590}
591
592fn append_primary_output_context(parts: &mut Vec<String>, output: &Value) {
593 append_primary_output_context_with_exclusions(parts, output, &[]);
594}
595
596const LIST_RESULT_SUMMARY_FIELDS: &[&str] = &["items", "count", "total"];
597const FILE_RESULT_SUMMARY_FIELDS: &[&str] = &["files", "total"];
598const MATCH_RESULT_SUMMARY_FIELDS: &[&str] = &["matches", "total_match_count", "matched_count", "count", "path"];
599const SEARCH_RESULT_SUMMARY_FIELDS: &[&str] = &["results", "returned", "total", "count", "query"];
600const EMPTY_RESULT_SUMMARY_FIELDS: &[&str] = &[];
601
602fn append_structured_result_metadata(parts: &mut Vec<String>, output: &Value, summary_fields: &[&str]) {
603 if let Some(metadata) = structured_stream_metadata(output, summary_fields) {
604 append_unique_line(parts, &metadata);
605 }
606}
607
608fn append_diff_stream_context(parts: &mut Vec<String>, output: &Value) {
609 for stream in aggregate_primary_streams(output) {
610 let stream_text = stream
611 .strip_prefix("[stdout]\n")
612 .or_else(|| stream.strip_prefix("[stderr]\n"))
613 .unwrap_or(&stream);
614 let already_in_diff = canonical_diff_previews(output).iter().any(|diff| {
615 diff.get("content")
616 .and_then(Value::as_str)
617 .is_some_and(|content| content.trim() == stream_text.trim())
618 });
619 if !already_in_diff {
620 append_unique_line(parts, &stream);
621 }
622 }
623}
624
625pub fn tool_output_payload_from_value(output: &Value) -> ToolOutputPayload {
629 if !output.is_object() {
630 let aggregated_output = match output {
631 Value::Null => String::new(),
632 Value::String(text) => text.to_string(),
633 _ => serde_json::to_string(output).unwrap_or_default(),
634 };
635 return ToolOutputPayload { aggregated_output, spool_path: None };
636 }
637
638 if let Some(spool_path) = output.get("spool_path").and_then(Value::as_str) {
639 let mut parts = Vec::new();
640 append_primary_output_context(&mut parts, output);
641 return ToolOutputPayload {
642 aggregated_output: parts.join("\n"),
643 spool_path: Some(spool_path.to_string()),
644 };
645 }
646
647 if let Some(diff) = diff_preview_output(output) {
648 let mut parts = vec![diff];
649 append_diff_stream_context(&mut parts, output);
650 append_primary_output_context_with_exclusions(&mut parts, output, &["diff", "diff_preview"]);
651 return ToolOutputPayload {
652 aggregated_output: parts.join("\n"),
653 spool_path: None,
654 };
655 }
656
657 let mut primary_text = aggregate_primary_streams(output);
658
659 if !primary_text.is_empty() {
660 append_primary_output_context(&mut primary_text, output);
661 return ToolOutputPayload {
662 aggregated_output: primary_text.join("\n"),
663 spool_path: None,
664 };
665 }
666
667 let (structured_summary, summary_fields) = if let Some(items) = output.get("items").and_then(Value::as_array) {
668 (Some(summarize_list_items(output, items)), LIST_RESULT_SUMMARY_FIELDS)
669 } else if let Some(files) = output.get("files").and_then(Value::as_array) {
670 (Some(summarize_file_list(output, files)), FILE_RESULT_SUMMARY_FIELDS)
671 } else if let Some(matches) = output.get("matches").and_then(Value::as_array) {
672 (Some(summarize_matches(output, matches)), MATCH_RESULT_SUMMARY_FIELDS)
673 } else if let Some(results) = output.get("results").and_then(Value::as_array) {
674 (Some(summarize_search_results(output, results)), SEARCH_RESULT_SUMMARY_FIELDS)
679 } else {
680 (
681 output
682 .as_object()
683 .map(|obj| {
684 obj.keys()
685 .filter(|key| key.as_str() != "success")
686 .take(4)
687 .cloned()
688 .collect::<Vec<_>>()
689 })
690 .filter(|keys| !keys.is_empty())
691 .map(|keys| format!("Structured result with fields: {}", keys.join(", "))),
692 EMPTY_RESULT_SUMMARY_FIELDS,
693 )
694 };
695
696 let mut parts = Vec::new();
697 if let Some(summary) = structured_summary.as_deref() {
698 append_unique_line(&mut parts, summary);
699 }
700 if let Some(text) = trimmed_error_message(output) {
701 append_unique_line(&mut parts, text);
702 }
703 for key in ["message", "critical_note", "hint", "next_action", "error_message"] {
704 if let Some(text) = trimmed_string_field(output, key) {
705 append_unique_line(&mut parts, text);
706 }
707 }
708 append_structured_result_metadata(&mut parts, output, summary_fields);
709
710 ToolOutputPayload {
711 aggregated_output: parts.join("\n"),
712 spool_path: None,
713 }
714}
715
716#[derive(Debug, Default)]
718pub struct SharedLifecycleEmitter {
719 next_item_index: u64,
720 assistant: StreamingTextState,
721 reasoning: StreamingTextState,
722 reasoning_stage: Option<String>,
723 tool_calls: HashMap<String, ToolCallStreamState>,
724 pending_events: Vec<ThreadEvent>,
725}
726
727impl SharedLifecycleEmitter {
728 #[must_use]
730 pub fn next_item_id(&mut self) -> String {
731 let id = self.next_item_index;
732 self.next_item_index += 1;
733 format!("item_{id}")
734 }
735
736 pub fn emit_completed_agent_message(&mut self, text: &str) {
738 if text.trim().is_empty() {
739 return;
740 }
741 let item_id = self.next_item_id();
742 self.pending_events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent {
743 item: ThreadItem {
744 context: None,
745 id: item_id,
746 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: text.to_string() }),
747 },
748 }));
749 }
750
751 pub fn replace_assistant_text(&mut self, text: &str) -> bool {
753 replace_stream_text(&mut self.assistant, text)
754 }
755
756 #[must_use]
758 pub fn assistant_started(&self) -> bool {
759 self.assistant.started
760 }
761
762 #[must_use]
764 pub fn assistant_len(&self) -> usize {
765 self.assistant.text.len()
766 }
767
768 pub fn append_assistant_delta(&mut self, delta: &str) -> bool {
770 append_stream_delta(&mut self.assistant, delta)
771 }
772
773 pub fn emit_assistant_snapshot(&mut self, item_id: Option<String>) -> bool {
775 let item_id = item_id.unwrap_or_else(|| self.next_item_id());
776 emit_text_snapshot(&mut self.pending_events, &mut self.assistant, item_id, |text| {
777 ThreadItemDetails::AgentMessage(AgentMessageItem { text })
778 })
779 }
780
781 pub fn complete_assistant_stream(&mut self) -> bool {
783 complete_text_stream(&mut self.pending_events, &mut self.assistant, |text| {
784 ThreadItemDetails::AgentMessage(AgentMessageItem { text })
785 })
786 }
787
788 pub fn emit_completed_reasoning(&mut self, text: &str) {
790 if text.trim().is_empty() {
791 return;
792 }
793 let item_id = self.next_item_id();
794 self.pending_events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent {
795 item: ThreadItem {
796 context: None,
797 id: item_id,
798 details: ThreadItemDetails::Reasoning(Box::new(ReasoningItem {
799 text: text.to_string(),
800 stage: self.reasoning_stage.clone(),
801 })),
802 },
803 }));
804 }
805
806 pub fn replace_reasoning_text(&mut self, text: &str) -> bool {
808 replace_stream_text(&mut self.reasoning, text)
809 }
810
811 pub fn append_reasoning_delta(&mut self, delta: &str) -> bool {
813 append_stream_delta(&mut self.reasoning, delta)
814 }
815
816 pub fn set_reasoning_stage(&mut self, stage: Option<String>) -> bool {
818 if self.reasoning_stage == stage {
819 return false;
820 }
821 self.reasoning_stage = stage;
822 true
823 }
824
825 #[must_use]
827 pub fn reasoning_len(&self) -> usize {
828 self.reasoning.text.len()
829 }
830
831 #[must_use]
833 pub fn reasoning_started(&self) -> bool {
834 self.reasoning.started
835 }
836
837 pub fn emit_reasoning_snapshot(&mut self, item_id: Option<String>) -> bool {
839 let item_id = item_id.unwrap_or_else(|| self.next_item_id());
840 let stage = self.reasoning_stage.clone();
841 emit_text_snapshot(&mut self.pending_events, &mut self.reasoning, item_id, move |text| {
842 ThreadItemDetails::Reasoning(Box::new(ReasoningItem { text, stage: stage.clone() }))
843 })
844 }
845
846 pub fn emit_reasoning_stage_update(&mut self) -> bool {
848 if !self.reasoning.started {
849 return false;
850 }
851 let Some(item_id) = self.reasoning.item_id.clone() else {
852 return false;
853 };
854 self.pending_events.push(ThreadEvent::ItemUpdated(ItemUpdatedEvent {
855 item: ThreadItem {
856 context: None,
857 id: item_id,
858 details: ThreadItemDetails::Reasoning(Box::new(ReasoningItem {
859 text: self.reasoning.text.clone(),
860 stage: self.reasoning_stage.clone(),
861 })),
862 },
863 }));
864 true
865 }
866
867 pub fn complete_reasoning_stream(&mut self) -> bool {
869 let stage = self.reasoning_stage.clone();
870 complete_text_stream(&mut self.pending_events, &mut self.reasoning, move |text| {
871 ThreadItemDetails::Reasoning(Box::new(ReasoningItem { text, stage: stage.clone() }))
872 })
873 }
874
875 pub fn start_tool_call(&mut self, call_id: &str, tool_name: Option<String>, item_id: Option<String>) -> bool {
877 let generated_item_id = item_id.unwrap_or_else(|| self.next_item_id());
878 let buffer = self
879 .tool_calls
880 .entry(call_id.to_string())
881 .or_insert_with(|| ToolCallStreamState {
882 item_id: generated_item_id,
883 name: None,
884 arguments: String::new(),
885 started: false,
886 last_emitted_args_len: 0,
887 update_events: 0,
888 });
889
890 if buffer.name.is_none() {
891 buffer.name = tool_name;
892 }
893 if buffer.started {
894 return false;
895 }
896
897 buffer.started = true;
898 self.pending_events.push(tool_started_event(
899 buffer.item_id.clone(),
900 buffer.name.as_deref().unwrap_or_default(),
901 None,
902 Some(call_id),
903 ));
904 true
905 }
906
907 pub fn append_tool_call_delta(
909 &mut self,
910 call_id: &str,
911 delta: &str,
912 tool_name: Option<String>,
913 item_id: Option<String>,
914 ) -> bool {
915 if delta.is_empty() {
916 return false;
917 }
918
919 let generated_item_id = item_id.unwrap_or_else(|| self.next_item_id());
920 let buffer = self
921 .tool_calls
922 .entry(call_id.to_string())
923 .or_insert_with(|| ToolCallStreamState {
924 item_id: generated_item_id,
925 name: None,
926 arguments: String::new(),
927 started: false,
928 last_emitted_args_len: 0,
929 update_events: 0,
930 });
931
932 if !buffer.started {
933 buffer.started = true;
934 if buffer.name.is_none() {
935 buffer.name = tool_name;
936 }
937 self.pending_events.push(tool_started_event(
938 buffer.item_id.clone(),
939 buffer.name.as_deref().unwrap_or_default(),
940 None,
941 Some(call_id),
942 ));
943 } else if buffer.name.is_none() {
944 buffer.name = tool_name;
945 }
946
947 buffer.arguments.push_str(delta);
948 let new_len = buffer.arguments.len();
953 let should_emit = buffer.update_events == 0
954 || (buffer.update_events < MAX_TOOL_ARG_UPDATE_EVENTS
955 && new_len.saturating_sub(buffer.last_emitted_args_len) >= MIN_TOOL_ARG_UPDATE_BYTES);
956 if should_emit {
957 buffer.last_emitted_args_len = new_len;
958 buffer.update_events += 1;
959 let arguments = progress_tool_arguments(&buffer.arguments);
960 self.pending_events.push(tool_invocation_updated_event(
961 buffer.item_id.clone(),
962 buffer.name.as_deref().unwrap_or_default(),
963 Some(&arguments),
964 Some(call_id),
965 ToolCallStatus::InProgress,
966 ));
967 }
968 true
969 }
970
971 pub fn complete_tool_call(&mut self, call_id: &str, status: ToolCallStatus, outcome: Option<ToolOutcome>) -> bool {
972 let Some(buffer) = self.tool_calls.remove(call_id) else {
973 return false;
974 };
975 if !buffer.started {
976 return false;
977 }
978
979 let arguments = if buffer.arguments.is_empty() {
980 None
981 } else {
982 Some(progress_tool_arguments(&buffer.arguments))
983 };
984 let resolved_outcome = outcome.unwrap_or_else(|| tool_outcome_from_status(&status));
985 self.pending_events.push(tool_invocation_completed_event(
986 buffer.item_id,
987 buffer.name.as_deref().unwrap_or_default(),
988 arguments.as_ref(),
989 Some(call_id),
990 status,
991 resolved_outcome,
992 ));
993 true
994 }
995
996 #[must_use]
997 pub fn tool_call_item_id(&self, call_id: &str) -> Option<&str> {
998 self.tool_calls.get(call_id).map(|buffer| buffer.item_id.as_str())
999 }
1000
1001 pub fn sync_tool_call_arguments(
1002 &mut self,
1003 call_id: &str,
1004 arguments: &str,
1005 tool_name: Option<String>,
1006 item_id: Option<String>,
1007 ) -> bool {
1008 let generated_item_id = item_id.unwrap_or_else(|| self.next_item_id());
1009 let buffer = self
1010 .tool_calls
1011 .entry(call_id.to_string())
1012 .or_insert_with(|| ToolCallStreamState {
1013 item_id: generated_item_id,
1014 name: None,
1015 arguments: String::new(),
1016 started: false,
1017 last_emitted_args_len: 0,
1018 update_events: 0,
1019 });
1020
1021 if buffer.name.is_none() {
1022 buffer.name = tool_name;
1023 }
1024
1025 if !buffer.started {
1026 buffer.started = true;
1027 self.pending_events.push(tool_started_event(
1028 buffer.item_id.clone(),
1029 buffer.name.as_deref().unwrap_or_default(),
1030 None,
1031 Some(call_id),
1032 ));
1033 }
1034
1035 if buffer.arguments == arguments {
1036 return false;
1037 }
1038
1039 buffer.arguments.clear();
1040 buffer.arguments.push_str(arguments);
1041 let args = progress_tool_arguments(&buffer.arguments);
1042 self.pending_events.push(tool_invocation_updated_event(
1043 buffer.item_id.clone(),
1044 buffer.name.as_deref().unwrap_or_default(),
1045 Some(&args),
1046 Some(call_id),
1047 ToolCallStatus::InProgress,
1048 ));
1049 buffer.last_emitted_args_len = buffer.arguments.len();
1050 buffer.update_events = buffer.update_events.saturating_add(1);
1051 true
1052 }
1053
1054 pub fn complete_open_items(&mut self) {
1055 self.complete_open_text_items();
1056 self.complete_open_tool_calls_with_status(ToolCallStatus::Completed);
1057 }
1058
1059 pub fn complete_open_text_items(&mut self) {
1060 let _ = self.complete_assistant_stream();
1061 let _ = self.complete_reasoning_stream();
1062 }
1063
1064 pub fn complete_open_items_with_tool_status(&mut self, status: ToolCallStatus) {
1065 self.complete_open_text_items();
1066 self.complete_open_tool_calls_with_status(status);
1067 }
1068
1069 pub fn complete_open_tool_calls_with_status(&mut self, status: ToolCallStatus) {
1070 let call_ids = self.tool_calls.keys().cloned().collect::<Vec<_>>();
1071 for call_id in call_ids {
1072 let _ = self.complete_tool_call(&call_id, status.clone(), None);
1073 }
1074 }
1075
1076 pub fn flush_open_tool_call_arguments(&mut self) {
1088 let call_ids: Vec<String> = self.tool_calls.keys().cloned().collect();
1089 for call_id in call_ids {
1090 let Some(buffer) = self.tool_calls.get_mut(&call_id) else {
1091 continue;
1092 };
1093 if buffer.arguments.is_empty() || buffer.last_emitted_args_len == buffer.arguments.len() {
1094 continue;
1095 }
1096 let arguments = progress_tool_arguments(&buffer.arguments);
1097 self.pending_events.push(tool_invocation_updated_event(
1098 buffer.item_id.clone(),
1099 buffer.name.as_deref().unwrap_or_default(),
1100 Some(&arguments),
1101 Some(&call_id),
1102 ToolCallStatus::InProgress,
1103 ));
1104 buffer.last_emitted_args_len = buffer.arguments.len();
1105 }
1106 }
1107
1108 #[must_use]
1109 pub fn drain_events(&mut self) -> Vec<ThreadEvent> {
1110 std::mem::take(&mut self.pending_events)
1111 }
1112}
1113
1114fn replace_stream_text(state: &mut StreamingTextState, text: &str) -> bool {
1115 if state.text == text {
1116 return false;
1117 }
1118 state.text.clear();
1119 state.text.push_str(text);
1120 true
1121}
1122
1123fn append_stream_delta(state: &mut StreamingTextState, delta: &str) -> bool {
1124 if delta.is_empty() {
1125 return false;
1126 }
1127 state.text.push_str(delta);
1128 true
1129}
1130
1131fn emit_text_snapshot(
1132 pending_events: &mut Vec<ThreadEvent>,
1133 state: &mut StreamingTextState,
1134 item_id: String,
1135 build_details: impl FnOnce(String) -> ThreadItemDetails,
1136) -> bool {
1137 if state.text.trim().is_empty() {
1138 return false;
1139 }
1140
1141 let item_id = state.item_id.get_or_insert(item_id).clone();
1142 let item = ThreadItem {
1143 context: None,
1144 id: item_id,
1145 details: build_details(state.text.clone()),
1146 };
1147
1148 if state.started {
1149 pending_events.push(ThreadEvent::ItemUpdated(ItemUpdatedEvent { item }));
1150 } else {
1151 state.started = true;
1152 pending_events.push(ThreadEvent::ItemStarted(ItemStartedEvent { item }));
1153 }
1154 true
1155}
1156
1157fn complete_text_stream(
1158 pending_events: &mut Vec<ThreadEvent>,
1159 state: &mut StreamingTextState,
1160 build_details: impl FnOnce(String) -> ThreadItemDetails,
1161) -> bool {
1162 if !state.started {
1163 return false;
1164 }
1165
1166 let Some(item_id) = state.item_id.take() else {
1167 state.started = false;
1168 state.text.clear();
1169 return false;
1170 };
1171
1172 state.started = false;
1173 let text = std::mem::take(&mut state.text);
1174 pending_events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent {
1175 item: ThreadItem {
1176 context: None,
1177 id: item_id,
1178 details: build_details(text),
1179 },
1180 }));
1181 true
1182}
1183
1184#[must_use]
1185pub fn tool_output_item_id(call_item_id: &str) -> String {
1186 format!("{call_item_id}:output")
1187}
1188
1189fn tool_invocation_item(
1190 item_id: String,
1191 tool_name: &str,
1192 arguments: Option<&Value>,
1193 tool_call_id: Option<&str>,
1194 status: ToolCallStatus,
1195 outcome: Option<ToolOutcome>,
1196) -> ThreadItem {
1197 ThreadItem {
1198 context: None,
1199 id: item_id,
1200 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
1201 tool_name: tool_name.to_string(),
1202 arguments: arguments.cloned(),
1203 tool_call_id: tool_call_id.map(str::to_string),
1204 status,
1205 outcome,
1206 })),
1207 }
1208}
1209
1210fn tool_output_item(
1211 call_item_id: &str,
1212 tool_call_id: Option<&str>,
1213 status: ToolCallStatus,
1214 exit_code: Option<i32>,
1215 spool_path: Option<&str>,
1216 output: impl Into<String>,
1217) -> ThreadItem {
1218 ThreadItem {
1219 context: None,
1220 id: tool_output_item_id(call_item_id),
1221 details: ThreadItemDetails::ToolOutput(Box::new(ToolOutputItem {
1222 call_id: call_item_id.to_string(),
1223 tool_call_id: tool_call_id.map(str::to_string),
1224 spool_path: spool_path.map(str::to_string),
1225 output: output.into(),
1226 exit_code,
1227 status,
1228 })),
1229 }
1230}
1231
1232#[must_use]
1233pub fn tool_started_event(
1234 item_id: String,
1235 tool_name: &str,
1236 arguments: Option<&Value>,
1237 tool_call_id: Option<&str>,
1238) -> ThreadEvent {
1239 ThreadEvent::ItemStarted(ItemStartedEvent {
1240 item: tool_invocation_item(item_id, tool_name, arguments, tool_call_id, ToolCallStatus::InProgress, None),
1241 })
1242}
1243
1244#[must_use]
1245pub fn tool_invocation_updated_event(
1246 item_id: String,
1247 tool_name: &str,
1248 arguments: Option<&Value>,
1249 tool_call_id: Option<&str>,
1250 status: ToolCallStatus,
1251) -> ThreadEvent {
1252 ThreadEvent::ItemUpdated(ItemUpdatedEvent {
1253 item: tool_invocation_item(item_id, tool_name, arguments, tool_call_id, status, None),
1254 })
1255}
1256
1257#[must_use]
1258pub fn tool_invocation_completed_event(
1259 item_id: String,
1260 tool_name: &str,
1261 arguments: Option<&Value>,
1262 tool_call_id: Option<&str>,
1263 status: ToolCallStatus,
1264 outcome: ToolOutcome,
1265) -> ThreadEvent {
1266 ThreadEvent::ItemCompleted(ItemCompletedEvent {
1267 item: tool_invocation_item(item_id, tool_name, arguments, tool_call_id, status, Some(outcome)),
1268 })
1269}
1270
1271#[must_use]
1272pub fn tool_output_started_event(call_item_id: String, tool_call_id: Option<&str>) -> ThreadEvent {
1273 ThreadEvent::ItemStarted(ItemStartedEvent {
1274 item: tool_output_item(&call_item_id, tool_call_id, ToolCallStatus::InProgress, None, None, String::new()),
1275 })
1276}
1277
1278#[must_use]
1279pub fn tool_output_updated_event(
1280 call_item_id: String,
1281 tool_call_id: Option<&str>,
1282 output: impl Into<String>,
1283) -> ThreadEvent {
1284 ThreadEvent::ItemUpdated(ItemUpdatedEvent {
1285 item: tool_output_item(&call_item_id, tool_call_id, ToolCallStatus::InProgress, None, None, output),
1286 })
1287}
1288
1289#[must_use]
1290pub fn tool_output_completed_event(
1291 call_item_id: String,
1292 tool_call_id: Option<&str>,
1293 status: ToolCallStatus,
1294 exit_code: Option<i32>,
1295 spool_path: Option<&str>,
1296 output: impl Into<String>,
1297) -> ThreadEvent {
1298 ThreadEvent::ItemCompleted(ItemCompletedEvent {
1299 item: tool_output_item(&call_item_id, tool_call_id, status, exit_code, spool_path, output),
1300 })
1301}
1302
1303#[must_use]
1304#[cold]
1305pub fn error_item_completed_event(item_id: String, message: impl Into<String>) -> ThreadEvent {
1306 ThreadEvent::ItemCompleted(ItemCompletedEvent {
1307 item: ThreadItem {
1308 context: None,
1309 id: item_id,
1310 details: ThreadItemDetails::Error(ErrorItem { message: message.into() }),
1311 },
1312 })
1313}
1314
1315fn progress_tool_arguments(arguments: &str) -> Value {
1316 serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321 #[test]
1322 fn explanation_file_attribution_ignores_noops_failures_and_unrelated_reads() {
1323 let args = serde_json::json!({"path":"a.rs", "content":"same"});
1324 let noop = serde_json::json!({"success":true,"diff":[{"path":"a.rs","operation":"updated","is_empty":true,"content":""}]});
1325 assert!(file_change_completed_event("call", "write_file", &args, &noop).is_none());
1326 let changed = serde_json::json!({"success":true,"diff":[{"path":"a.rs","operation":"updated","content":"--- a/a.rs\n+++ b/a.rs\n@@ -1 +1 @@\n-old\n+new","additions":1,"deletions":1},{"path":"b.rs","operation":"updated","is_empty":true}]});
1327 let event = file_change_completed_event("call", "write_file", &args, &changed).unwrap();
1328 let ThreadEvent::ItemCompleted(e) = event else {
1329 panic!("expected completed file change")
1330 };
1331 let ThreadItemDetails::FileChange(change) = e.item.details else {
1332 panic!("expected file change")
1333 };
1334 assert_eq!(change.changes.len(), 1);
1335 assert_eq!(change.changes[0].path, "a.rs");
1336 assert_eq!(change.additions, Some(1));
1337 assert_eq!(change.diff_incomplete, None);
1338 let incomplete = serde_json::json!({"success":true,"diff":[{"path":"a.rs","operation":"updated","content":"+new","additions":1,"deletions":0,"truncated":true},{"path":"b.rs","operation":"updated","skipped":true}]});
1339 let ThreadEvent::ItemCompleted(event) =
1340 file_change_completed_event("second", "write_file", &args, &incomplete).unwrap()
1341 else {
1342 panic!("file event")
1343 };
1344 let ThreadItemDetails::FileChange(change) = event.item.details else {
1345 panic!("file details")
1346 };
1347 assert_eq!(change.diff_incomplete, Some(true));
1348 assert_eq!(change.additions, None, "partial totals must remain unavailable");
1349 assert!(file_change_completed_event("call", "read_file", &args, &changed).is_none());
1350 assert!(
1351 file_change_completed_event(
1352 "call",
1353 "write_file",
1354 &args,
1355 &serde_json::json!({"success":false,"modified_files":["a.rs"]})
1356 )
1357 .is_none()
1358 );
1359 }
1360 use serde_json::json;
1361
1362 use super::*;
1363
1364 #[test]
1365 fn tool_started_event_omits_arguments_when_absent() {
1366 let event = tool_started_event("item".to_string(), "shell", None, Some("call_1"));
1367 let ThreadEvent::ItemStarted(ItemStartedEvent { item }) = event else {
1368 panic!("expected started item");
1369 };
1370 let ThreadItemDetails::ToolInvocation(details) = item.details else {
1371 panic!("expected tool invocation");
1372 };
1373 assert!(details.arguments.is_none());
1374 assert_eq!(details.tool_name, "shell");
1375 }
1376
1377 #[test]
1378 fn tool_output_updated_event_streams_in_progress_output() {
1379 let event = tool_output_updated_event("item".to_string(), Some("call_1"), "abc");
1380 let ThreadEvent::ItemUpdated(ItemUpdatedEvent { item }) = event else {
1381 panic!("expected updated item");
1382 };
1383 let ThreadItemDetails::ToolOutput(details) = item.details else {
1384 panic!("expected tool output");
1385 };
1386 assert_eq!(details.call_id, "item");
1387 assert_eq!(details.tool_call_id.as_deref(), Some("call_1"));
1388 assert_eq!(details.output, "abc");
1389 assert_eq!(details.status, ToolCallStatus::InProgress);
1390 }
1391
1392 #[test]
1393 fn tool_output_payload_preserves_spool_reference() {
1394 let payload = tool_output_payload_from_value(&json!({
1395 "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
1396 "output": "ignored"
1397 }));
1398
1399 assert_eq!(payload.aggregated_output, "");
1400 assert_eq!(payload.spool_path.as_deref(), Some(".vtcode/context/tool_outputs/run-1.txt"));
1401 }
1402
1403 #[test]
1404 fn tool_output_payload_preserves_spooled_metadata_with_reference() {
1405 let payload = tool_output_payload_from_value(&json!({
1406 "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
1407 "preview": "bounded preview",
1408 "spooled_bytes": 42,
1409 "stderr_preview": "diagnostic preview",
1410 "generated_files": ["src/generated.rs"],
1411 "next_action": "Inspect the generated file."
1412 }));
1413
1414 assert_eq!(payload.spool_path.as_deref(), Some(".vtcode/context/tool_outputs/run-1.txt"));
1415 assert!(payload.aggregated_output.contains("bounded preview"));
1416 assert!(payload.aggregated_output.contains("\"spooled_bytes\": 42"));
1417 assert!(payload.aggregated_output.contains("diagnostic preview"));
1418 assert!(payload.aggregated_output.contains("src/generated.rs"));
1419 assert!(payload.aggregated_output.contains("Inspect the generated file."));
1420 }
1421
1422 #[test]
1423 fn tool_output_payload_includes_apply_patch_diff_content() {
1424 let payload = tool_output_payload_from_value(&json!({
1425 "success": true,
1426 "applied": ["[1/1] Updated file: README.md (1 chunk)"],
1427 "modified_files": ["README.md"],
1428 "diff": [{
1429 "path": "README.md",
1430 "content": "diff --git a/README.md b/README.md\n-before\n+after\n",
1431 "skipped": false
1432 }]
1433 }));
1434
1435 assert!(payload.aggregated_output.contains("diff --git a/README.md b/README.md"));
1436 assert!(payload.aggregated_output.contains("-before"));
1437 assert!(payload.aggregated_output.contains("+after"));
1438 }
1439
1440 #[test]
1441 fn tool_output_payload_normalizes_legacy_write_diff_preview() {
1442 let payload = tool_output_payload_from_value(&json!({
1443 "success": true,
1444 "path": "README.md",
1445 "diff_preview": {
1446 "content": "diff --git a/README.md b/README.md\n-before\n+after\n",
1447 "skipped": false
1448 }
1449 }));
1450
1451 assert!(payload.aggregated_output.contains("diff preview for README.md"));
1452 assert!(payload.aggregated_output.contains("-before"));
1453 assert!(payload.aggregated_output.contains("+after"));
1454 }
1455
1456 #[test]
1457 fn tool_output_payload_preserves_diff_metadata_without_repeating_preview() {
1458 let payload = tool_output_payload_from_value(&json!({
1459 "success": true,
1460 "applied": ["Updated README.md"],
1461 "modified_files": ["README.md"],
1462 "diff": [{
1463 "path": "README.md",
1464 "content": "diff --git a/README.md b/README.md\n-before\n+after\n",
1465 "skipped": false
1466 }],
1467 "next_action": "Run the documentation checks."
1468 }));
1469
1470 assert!(payload.aggregated_output.contains("Updated README.md"));
1471 assert!(payload.aggregated_output.contains("README.md"));
1472 assert!(payload.aggregated_output.contains("Run the documentation checks."));
1473 assert_eq!(payload.aggregated_output.matches("diff --git a/README.md b/README.md").count(), 1);
1474 }
1475
1476 #[test]
1477 fn tool_output_payload_summarizes_list_results() {
1478 let payload = tool_output_payload_from_value(&json!({
1479 "items": [
1480 {"name": "app.rs", "path": "vtcode-tui/src/app.rs", "type": "file"},
1481 {"name": "core_tui", "path": "vtcode-tui/src/core_tui", "type": "directory"},
1482 {"name": "lib.rs", "path": "vtcode-tui/src/lib.rs", "type": "file"}
1483 ],
1484 "count": 3,
1485 "total": 11
1486 }));
1487
1488 assert_eq!(payload.spool_path, None);
1489 assert!(payload.aggregated_output.contains("Listed 11 items"));
1490 assert!(payload.aggregated_output.contains("2 files, 1 directory"));
1491 assert!(payload.aggregated_output.contains("vtcode-tui/src/app.rs"));
1492 }
1493
1494 #[test]
1495 fn tool_output_payload_combines_list_summary_with_message() {
1496 let payload = tool_output_payload_from_value(&json!({
1497 "items": [
1498 {"name": "app.rs", "path": "vtcode-tui/src/app.rs", "type": "file"},
1499 {"name": "core_tui", "path": "vtcode-tui/src/core_tui", "type": "directory"}
1500 ],
1501 "count": 2,
1502 "total": 2,
1503 "message": "[+3 more items]"
1504 }));
1505
1506 assert!(payload.aggregated_output.contains("Listed 2 items"));
1507 assert!(payload.aggregated_output.contains("[+3 more items]"));
1508 }
1509
1510 #[test]
1511 fn tool_output_payload_summarizes_match_results() {
1512 let payload = tool_output_payload_from_value(&json!({
1513 "matches": [
1514 {"path": "src/main.rs", "line_number": 12},
1515 {"file": "src/lib.rs", "line_number": 9}
1516 ],
1517 "total_match_count": 7
1518 }));
1519
1520 assert_eq!(payload.spool_path, None);
1521 assert!(payload.aggregated_output.contains("Found 7 matches"));
1522 assert!(payload.aggregated_output.contains("src/main.rs"));
1523 assert!(payload.aggregated_output.contains("src/lib.rs"));
1524 }
1525
1526 #[test]
1527 fn tool_output_payload_summarizes_nested_match_paths() {
1528 let payload = tool_output_payload_from_value(&json!({
1529 "matches": [
1530 {
1531 "type": "match",
1532 "data": {
1533 "path": {"text": "vtcode-tui/src/core_tui/runner/mod.rs"},
1534 "line_number": 27,
1535 "lines": {"text": "runloop\n"}
1536 }
1537 }
1538 ],
1539 "total_match_count": 1
1540 }));
1541
1542 assert!(payload.aggregated_output.contains("Found 1 match"));
1543 assert!(payload.aggregated_output.contains("vtcode-tui/src/core_tui/runner/mod.rs"));
1544 }
1545
1546 #[test]
1547 fn tool_output_payload_reports_empty_match_set() {
1548 let payload = tool_output_payload_from_value(&json!({
1549 "matches": [],
1550 "path": "crates/codegen/vtcode-core/src"
1551 }));
1552
1553 assert_eq!(payload.aggregated_output, "No matches found");
1554 assert_eq!(payload.spool_path, None);
1555 }
1556
1557 #[test]
1558 fn tool_output_payload_summarizes_code_search_results() {
1559 let payload = tool_output_payload_from_value(&json!({
1561 "query": "startup timing",
1562 "filters": {"path": "src", "file_types": null, "result_types": null, "max_results": 10},
1563 "results": [
1564 {"result_type": "definition", "path": "src/main.rs", "line": 42},
1565 {"result_type": "usage", "path": "src/startup/mod.rs", "line": 7},
1566 {"result_type": "usage", "path": "src/main.rs", "line": 42}
1567 ],
1568 "returned": 3
1569 }));
1570
1571 assert_eq!(payload.spool_path, None);
1572 assert!(payload.aggregated_output.contains("Returned 3 results"));
1573 assert!(payload.aggregated_output.contains("for `startup timing`"));
1574 assert!(payload.aggregated_output.contains("src/main.rs:42"));
1575 assert!(payload.aggregated_output.contains("src/startup/mod.rs:7"));
1576 assert_eq!(payload.aggregated_output.matches("src/main.rs:42").count(), 1);
1578 }
1579
1580 #[test]
1581 fn tool_output_payload_reports_empty_code_search_results() {
1582 let payload = tool_output_payload_from_value(&json!({
1583 "query": "nonexistent_symbol",
1584 "results": [],
1585 "returned": 0
1586 }));
1587
1588 assert_eq!(payload.aggregated_output, "No results found for `nonexistent_symbol`");
1589 }
1590
1591 #[test]
1592 fn tool_output_payload_includes_structured_recovery_guidance() {
1593 let payload = tool_output_payload_from_value(&json!({
1594 "matches": [],
1595 "path": "src/agent",
1596 "hint": "Pattern looks like a code fragment.",
1597 "next_action": "Retry with a larger parseable pattern."
1598 }));
1599
1600 assert!(payload.aggregated_output.contains("No matches found"));
1601 assert!(payload.aggregated_output.contains("Pattern looks like a code fragment."));
1602 assert!(payload.aggregated_output.contains("Retry with a larger parseable pattern."));
1603 }
1604
1605 #[test]
1606 fn tool_output_payload_preserves_distinct_stream_aliases_once() {
1607 let payload = tool_output_payload_from_value(&json!({
1608 "output": "merged stdout\nmerged stderr",
1609 "stdout": "merged stdout",
1610 "stderr": "merged stderr"
1611 }));
1612
1613 assert_eq!(payload.aggregated_output, "merged stdout\nmerged stderr");
1614 }
1615
1616 #[test]
1617 fn tool_output_payload_preserves_equal_named_streams_without_merged_output() {
1618 let payload = tool_output_payload_from_value(&json!({
1619 "stdout": "same output",
1620 "stderr": "same output"
1621 }));
1622
1623 assert!(payload.aggregated_output.contains("[stdout]\nsame output"));
1624 assert!(payload.aggregated_output.contains("[stderr]\nsame output"));
1625 assert_eq!(payload.aggregated_output.matches("same output").count(), 2);
1626 }
1627
1628 #[test]
1629 fn tool_output_payload_preserves_subset_named_streams_without_merged_output() {
1630 let payload = tool_output_payload_from_value(&json!({
1631 "stdout": "shared line\nstdout only",
1632 "stderr": "shared line"
1633 }));
1634
1635 assert!(payload.aggregated_output.contains("[stdout]\nshared line\nstdout only"));
1636 assert!(payload.aggregated_output.contains("[stderr]\nshared line"));
1637 assert_eq!(payload.aggregated_output.matches("shared line").count(), 2);
1638 }
1639
1640 #[test]
1641 fn tool_output_payload_preserves_overlapping_named_streams_with_merged_output() {
1642 let payload = tool_output_payload_from_value(&json!({
1643 "output": "shared line\nmerged only",
1644 "stdout": "shared line",
1645 "stderr": "shared line"
1646 }));
1647
1648 assert!(payload.aggregated_output.contains("[stdout]\nshared line"));
1649 assert!(payload.aggregated_output.contains("[stderr]\nshared line"));
1650 assert!(payload.aggregated_output.contains("shared line\nmerged only"));
1651 }
1652
1653 #[test]
1654 fn tool_output_payload_preserves_structured_metadata_with_streams() {
1655 let payload = tool_output_payload_from_value(&json!({
1656 "stdout": "command output",
1657 "json_result": {"answer": 42},
1658 "generated_files": {"files": ["src/generated.rs"]},
1659 "next_action": "Review the generated file."
1660 }));
1661
1662 assert!(payload.aggregated_output.contains("command output"));
1663 assert!(payload.aggregated_output.contains("Structured output:"));
1664 assert!(payload.aggregated_output.contains("src/generated.rs"));
1665 assert!(payload.aggregated_output.contains("\"answer\": 42"));
1666 assert!(payload.aggregated_output.contains("Review the generated file."));
1667 }
1668
1669 #[test]
1670 fn tool_output_payload_preserves_structured_only_result_metadata() {
1671 let payload = tool_output_payload_from_value(&json!({
1672 "json_result": {"answer": 42},
1673 "metadata_flag": false,
1674 "metadata_count": 0,
1675 }));
1676
1677 assert!(payload.aggregated_output.contains("Structured output:"));
1678 assert!(payload.aggregated_output.contains("\"answer\": 42"));
1679 assert!(payload.aggregated_output.contains("\"metadata_flag\": false"));
1680 assert!(payload.aggregated_output.contains("\"metadata_count\": 0"));
1681 }
1682
1683 #[test]
1684 fn tool_output_payload_preserves_structured_error_details() {
1685 let payload = tool_output_payload_from_value(&json!({
1686 "error": {
1687 "message": "command failed",
1688 "code": "E_COMMAND",
1689 "retryable": true,
1690 }
1691 }));
1692
1693 assert!(payload.aggregated_output.contains("command failed"));
1694 assert!(payload.aggregated_output.contains("\"code\": \"E_COMMAND\""));
1695 assert!(payload.aggregated_output.contains("\"retryable\": true"));
1696 }
1697
1698 #[test]
1699 fn tool_output_payload_preserves_streams_alongside_diff_preview() {
1700 let payload = tool_output_payload_from_value(&json!({
1701 "stdout": "apply completed",
1702 "stderr": "warning: generated file was already present",
1703 "diff": [{
1704 "path": "README.md",
1705 "content": "diff --git a/README.md b/README.md\n-before\n+after\n",
1706 "skipped": false,
1707 }]
1708 }));
1709
1710 assert!(payload.aggregated_output.contains("diff --git a/README.md b/README.md"));
1711 assert!(payload.aggregated_output.contains("[stdout]\napply completed"));
1712 assert!(
1713 payload
1714 .aggregated_output
1715 .contains("[stderr]\nwarning: generated file was already present")
1716 );
1717 }
1718
1719 #[test]
1720 fn tool_output_payload_preserves_non_object_results() {
1721 assert_eq!(tool_output_payload_from_value(&json!(" plain result\n")).aggregated_output, " plain result\n");
1722 assert_eq!(tool_output_payload_from_value(&json!(42)).aggregated_output, "42");
1723 assert_eq!(tool_output_payload_from_value(&json!(null)).aggregated_output, "");
1724 }
1725
1726 #[test]
1727 fn tool_invocation_completed_event_embeds_outcome() {
1728 let event = tool_invocation_completed_event(
1729 "tool_1".to_string(),
1730 "exec_command",
1731 Some(&json!({"cmd": "pwd"})),
1732 Some("call_1"),
1733 ToolCallStatus::Failed,
1734 ToolOutcome::HookDenied,
1735 );
1736 let ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) = event else {
1737 panic!("expected completed item");
1738 };
1739 let ThreadItemDetails::ToolInvocation(details) = item.details else {
1740 panic!("expected tool invocation");
1741 };
1742 assert_eq!(details.status, ToolCallStatus::Failed);
1743 assert_eq!(details.outcome, Some(ToolOutcome::HookDenied));
1744 }
1745
1746 #[test]
1747 fn complete_tool_call_infers_outcome_from_status() {
1748 let mut emitter = SharedLifecycleEmitter::default();
1749 let call_id = "call_1".to_string();
1750 emitter.start_tool_call(&call_id, Some("exec_command".to_string()), None);
1751 emitter.sync_tool_call_arguments(&call_id, "{\"cmd\":\"pwd\"}", Some("exec_command".to_string()), None);
1752 emitter.complete_tool_call(&call_id, ToolCallStatus::Completed, None);
1753 let events = emitter.drain_events();
1754 let ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) = &events[2] else {
1756 panic!("expected completed item at index 2, got {:?}", events[0]);
1757 };
1758 let ThreadItemDetails::ToolInvocation(details) = &item.details else {
1759 panic!("expected tool invocation");
1760 };
1761 assert_eq!(details.outcome, Some(ToolOutcome::Success));
1762 }
1763
1764 #[test]
1765 fn append_tool_call_delta_throttles_intermediate_updates() {
1766 let mut emitter = SharedLifecycleEmitter::default();
1767 let call_id = "call_t".to_string();
1768 emitter.start_tool_call(&call_id, Some("exec_command".to_string()), None);
1769 let _ = emitter.drain_events();
1771
1772 for _ in 0..600 {
1776 emitter.append_tool_call_delta(&call_id, "x", None, None);
1777 }
1778 let events = emitter.drain_events();
1779 let item_updated_count = events.iter().filter(|e| matches!(e, ThreadEvent::ItemUpdated(_))).count();
1780 assert_eq!(
1781 item_updated_count, 2,
1782 "expected 2 throttled ItemUpdated events for 600 1-byte deltas, got {item_updated_count}"
1783 );
1784
1785 emitter.complete_tool_call(&call_id, ToolCallStatus::Completed, None);
1787 let events = emitter.drain_events();
1788 let completed = events
1789 .iter()
1790 .find_map(|e| {
1791 if let ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) = e {
1792 Some(item)
1793 } else {
1794 None
1795 }
1796 })
1797 .expect("should have a completed event");
1798 let ThreadItemDetails::ToolInvocation(details) = &completed.details else {
1799 panic!("expected tool invocation in completed event");
1800 };
1801 assert!(
1802 details.arguments.as_ref().is_some_and(|a| a.to_string().contains("xxx")),
1803 "completed event should carry the full accumulated arguments"
1804 );
1805 }
1806
1807 #[test]
1808 fn append_tool_call_delta_caps_intermediate_update_events() {
1809 let mut emitter = SharedLifecycleEmitter::default();
1810 let call_id = "call_c".to_string();
1811 emitter.start_tool_call(&call_id, Some("exec_command".to_string()), None);
1812 let _ = emitter.drain_events();
1813
1814 for _ in 0..5000 {
1818 emitter.append_tool_call_delta(&call_id, "x", None, None);
1819 }
1820 let events = emitter.drain_events();
1821 let item_updated_count = events.iter().filter(|e| matches!(e, ThreadEvent::ItemUpdated(_))).count();
1822 assert_eq!(
1823 item_updated_count, MAX_TOOL_ARG_UPDATE_EVENTS,
1824 "intermediate updates should be capped at MAX_TOOL_ARG_UPDATE_EVENTS, got {item_updated_count}"
1825 );
1826 }
1827
1828 #[test]
1829 fn flush_open_tool_call_arguments_emits_full_args_for_throttled_small_calls() {
1830 let mut emitter = SharedLifecycleEmitter::default();
1831 let call_id = "call_flush".to_string();
1832 emitter.start_tool_call(&call_id, Some("shell".to_string()), None);
1833 let _ = emitter.drain_events();
1834
1835 emitter.append_tool_call_delta(&call_id, "{\"cmd\":\"ec", None, None);
1839 emitter.append_tool_call_delta(&call_id, "ho hi\"}", None, None);
1840 let intermediate = emitter.drain_events();
1841 let intermediate_updated = intermediate.iter().filter(|e| matches!(e, ThreadEvent::ItemUpdated(_))).count();
1842 assert_eq!(intermediate_updated, 1, "first delta emits one eager update");
1843
1844 emitter.flush_open_tool_call_arguments();
1846 let flushed = emitter.drain_events();
1847 let updated = flushed
1848 .iter()
1849 .filter_map(|e| {
1850 if let ThreadEvent::ItemUpdated(ItemUpdatedEvent { item }) = e {
1851 Some(&item.details)
1852 } else {
1853 None
1854 }
1855 })
1856 .next_back();
1857 let ThreadItemDetails::ToolInvocation(details) = updated.expect("flush should emit an update") else {
1858 panic!("expected tool invocation update");
1859 };
1860 assert_eq!(
1861 details.arguments.as_ref().and_then(|a| a.get("cmd")).and_then(|c| c.as_str()),
1862 Some("echo hi"),
1863 "flush should carry the full accumulated arguments"
1864 );
1865 assert!(flushed.iter().all(|e| !matches!(e, ThreadEvent::ItemCompleted(_))));
1867 }
1868
1869 #[test]
1870 fn flush_open_tool_call_arguments_skips_redundant_flush() {
1871 let mut emitter = SharedLifecycleEmitter::default();
1872 let call_id = "call_skip".to_string();
1873 emitter.start_tool_call(&call_id, Some("shell".to_string()), None);
1874 let _ = emitter.drain_events();
1875
1876 let big = "x".repeat(MIN_TOOL_ARG_UPDATE_BYTES);
1880 emitter.append_tool_call_delta(&call_id, &big, None, None);
1881 let _ = emitter.drain_events();
1882
1883 emitter.flush_open_tool_call_arguments();
1884 let flushed = emitter.drain_events();
1885 assert!(flushed.is_empty(), "flush should skip when the last update already carried the full arguments");
1886 }
1887}