1use crate::exec::events::{
2 AgentMessageItem, ErrorItem, ItemCompletedEvent, ItemStartedEvent, ItemUpdatedEvent,
3 ReasoningItem, ThreadEvent, ThreadItem, ThreadItemDetails, ToolCallStatus, ToolInvocationItem,
4 ToolOutputItem,
5};
6use serde_json::Value;
7use std::collections::HashMap;
8use std::fmt::Write;
9
10#[derive(Debug, Clone, Default)]
11struct StreamingTextState {
12 item_id: Option<String>,
13 text: String,
14 started: bool,
15}
16
17#[derive(Debug, Clone)]
18struct ToolCallStreamState {
19 item_id: String,
20 name: Option<String>,
21 arguments: String,
22 started: bool,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ToolOutputPayload {
27 pub aggregated_output: String,
28 pub spool_path: Option<String>,
29}
30
31fn pluralize<'a>(count: u64, singular: &'a str, plural: &'a str) -> &'a str {
32 if count == 1 { singular } else { plural }
33}
34
35fn trimmed_string_field<'a>(output: &'a Value, key: &str) -> Option<&'a str> {
36 output
37 .get(key)
38 .and_then(Value::as_str)
39 .map(str::trim)
40 .filter(|text| !text.is_empty())
41}
42
43#[cold]
44fn trimmed_error_message(output: &Value) -> Option<&str> {
45 match output.get("error") {
46 Some(Value::String(message)) => Some(message.as_str()),
47 Some(Value::Object(error)) => error.get("message").and_then(Value::as_str),
48 _ => None,
49 }
50 .map(str::trim)
51 .filter(|text| !text.is_empty())
52}
53
54fn sample_strings_from_objects(items: &[Value], keys: &[&str], limit: usize) -> Vec<String> {
55 let mut samples = Vec::new();
56
57 for item in items {
58 let Some(value) = keys
59 .iter()
60 .find_map(|key| item.get(*key).and_then(Value::as_str))
61 .map(str::trim)
62 .filter(|text| !text.is_empty())
63 else {
64 continue;
65 };
66
67 if samples.iter().any(|sample| sample == value) {
68 continue;
69 }
70
71 samples.push(value.to_string());
72 if samples.len() >= limit {
73 break;
74 }
75 }
76
77 samples
78}
79
80fn match_path_text(item: &Value) -> Option<&str> {
81 item.get("path")
82 .and_then(Value::as_str)
83 .map(str::trim)
84 .filter(|text| !text.is_empty())
85 .or_else(|| {
86 item.get("file")
87 .and_then(Value::as_str)
88 .map(str::trim)
89 .filter(|text| !text.is_empty())
90 })
91 .or_else(|| {
92 item.get("data")
93 .and_then(Value::as_object)
94 .and_then(|data| data.get("path"))
95 .and_then(Value::as_object)
96 .and_then(|path| path.get("text"))
97 .and_then(Value::as_str)
98 .map(str::trim)
99 .filter(|text| !text.is_empty())
100 })
101}
102
103fn sample_match_paths(matches: &[Value], limit: usize) -> Vec<String> {
104 let mut samples = Vec::new();
105
106 for item in matches {
107 let Some(path) = match_path_text(item) else {
108 continue;
109 };
110
111 if samples.iter().any(|sample| sample == path) {
112 continue;
113 }
114
115 samples.push(path.to_string());
116 if samples.len() >= limit {
117 break;
118 }
119 }
120
121 samples
122}
123
124fn summarize_list_items(output: &Value, items: &[Value]) -> String {
125 let total = output
126 .get("total")
127 .or_else(|| output.get("count"))
128 .and_then(Value::as_u64)
129 .unwrap_or(items.len() as u64);
130
131 let (files, directories) = items
132 .iter()
133 .fold((0u64, 0u64), |(files, directories), item| {
134 match item.get("type").and_then(Value::as_str) {
135 Some("file") => (files + 1, directories),
136 Some("directory") => (files, directories + 1),
137 _ => (files, directories),
138 }
139 });
140
141 let mut summary = format!("Listed {total} {}", pluralize(total, "item", "items"));
142 if files > 0 || directories > 0 {
143 let _ = write!(
144 summary,
145 " ({} {}, {} {})",
146 files,
147 pluralize(files, "file", "files"),
148 directories,
149 pluralize(directories, "directory", "directories"),
150 );
151 }
152
153 let samples = sample_strings_from_objects(items, &["path", "name"], 3);
154 if !samples.is_empty() {
155 let _ = write!(summary, ": {}", samples.join(", "));
156 }
157
158 summary
159}
160
161fn summarize_file_list(output: &Value, files: &[Value]) -> String {
162 let total = output
163 .get("total")
164 .and_then(Value::as_u64)
165 .unwrap_or(files.len() as u64);
166 let mut summary = format!("Listed {total} {}", pluralize(total, "file", "files"));
167
168 let samples = files
169 .iter()
170 .filter_map(Value::as_str)
171 .map(str::trim)
172 .filter(|text| !text.is_empty())
173 .take(3)
174 .map(str::to_string)
175 .collect::<Vec<_>>();
176 if !samples.is_empty() {
177 let _ = write!(summary, ": {}", samples.join(", "));
178 }
179
180 summary
181}
182
183fn summarize_matches(output: &Value, matches: &[Value]) -> String {
184 let total = output
185 .get("total_match_count")
186 .or_else(|| output.get("matched_count"))
187 .or_else(|| output.get("count"))
188 .and_then(Value::as_u64)
189 .unwrap_or(matches.len() as u64);
190
191 if total == 0 {
192 return "No matches found".to_string();
193 }
194
195 let mut summary = format!("Found {total} {}", pluralize(total, "match", "matches"));
196
197 let samples = sample_match_paths(matches, 3);
198 if !samples.is_empty() {
199 let _ = write!(summary, " in {}", samples.join(", "));
200 } else if let Some(path) = trimmed_string_field(output, "path") {
201 let _ = write!(summary, " in {path}");
202 }
203
204 summary
205}
206
207fn append_unique_line(lines: &mut Vec<String>, line: &str) {
208 if !lines.iter().any(|existing| existing == line) {
209 lines.push(line.to_string());
210 }
211}
212
213pub fn tool_output_payload_from_value(output: &Value) -> ToolOutputPayload {
214 if let Some(spool_path) = output.get("spool_path").and_then(Value::as_str) {
215 return ToolOutputPayload {
216 aggregated_output: String::new(),
217 spool_path: Some(spool_path.to_string()),
218 };
219 }
220
221 let mut primary_text = Vec::new();
222 for key in ["output", "stdout", "stderr", "content"] {
223 if let Some(text) = trimmed_string_field(output, key) {
224 append_unique_line(&mut primary_text, text);
225 }
226 }
227
228 if !primary_text.is_empty() {
229 return ToolOutputPayload {
230 aggregated_output: primary_text.join("\n"),
231 spool_path: None,
232 };
233 }
234
235 let structured_summary = if let Some(items) = output.get("items").and_then(Value::as_array) {
236 Some(summarize_list_items(output, items))
237 } else if let Some(files) = output.get("files").and_then(Value::as_array) {
238 Some(summarize_file_list(output, files))
239 } else if let Some(matches) = output.get("matches").and_then(Value::as_array) {
240 Some(summarize_matches(output, matches))
241 } else {
242 output
243 .as_object()
244 .map(|obj| {
245 obj.keys()
246 .filter(|key| key.as_str() != "success")
247 .take(4)
248 .cloned()
249 .collect::<Vec<_>>()
250 })
251 .filter(|keys| !keys.is_empty())
252 .map(|keys| format!("Structured result with fields: {}", keys.join(", ")))
253 };
254
255 let mut parts = Vec::new();
256 if let Some(summary) = structured_summary.as_deref() {
257 append_unique_line(&mut parts, summary);
258 }
259 if let Some(text) = trimmed_error_message(output) {
260 append_unique_line(&mut parts, text);
261 }
262 for key in ["message", "critical_note", "hint", "next_action"] {
263 if let Some(text) = trimmed_string_field(output, key) {
264 append_unique_line(&mut parts, text);
265 }
266 }
267
268 ToolOutputPayload {
269 aggregated_output: parts.join("\n"),
270 spool_path: None,
271 }
272}
273
274#[derive(Debug, Default)]
276pub struct SharedLifecycleEmitter {
277 next_item_index: u64,
278 assistant: StreamingTextState,
279 reasoning: StreamingTextState,
280 reasoning_stage: Option<String>,
281 tool_calls: HashMap<String, ToolCallStreamState>,
282 pending_events: Vec<ThreadEvent>,
283}
284
285impl SharedLifecycleEmitter {
286 #[must_use]
287 pub fn next_item_id(&mut self) -> String {
288 let id = self.next_item_index;
289 self.next_item_index += 1;
290 format!("item_{id}")
291 }
292
293 pub fn emit_completed_agent_message(&mut self, text: &str) {
294 if text.trim().is_empty() {
295 return;
296 }
297 let item_id = self.next_item_id();
298 self.pending_events
299 .push(ThreadEvent::ItemCompleted(ItemCompletedEvent {
300 item: ThreadItem {
301 id: item_id,
302 details: ThreadItemDetails::AgentMessage(AgentMessageItem {
303 text: text.to_string(),
304 }),
305 },
306 }));
307 }
308
309 pub fn replace_assistant_text(&mut self, text: &str) -> bool {
310 replace_stream_text(&mut self.assistant, text)
311 }
312
313 #[must_use]
314 pub fn assistant_started(&self) -> bool {
315 self.assistant.started
316 }
317
318 pub fn append_assistant_delta(&mut self, delta: &str) -> bool {
319 append_stream_delta(&mut self.assistant, delta)
320 }
321
322 pub fn emit_assistant_snapshot(&mut self, item_id: Option<String>) -> bool {
323 let item_id = item_id.unwrap_or_else(|| self.next_item_id());
324 emit_text_snapshot(
325 &mut self.pending_events,
326 &mut self.assistant,
327 item_id,
328 |text| ThreadItemDetails::AgentMessage(AgentMessageItem { text }),
329 )
330 }
331
332 pub fn complete_assistant_stream(&mut self) -> bool {
333 complete_text_stream(&mut self.pending_events, &mut self.assistant, |text| {
334 ThreadItemDetails::AgentMessage(AgentMessageItem { text })
335 })
336 }
337
338 pub fn emit_completed_reasoning(&mut self, text: &str) {
339 if text.trim().is_empty() {
340 return;
341 }
342 let item_id = self.next_item_id();
343 self.pending_events
344 .push(ThreadEvent::ItemCompleted(ItemCompletedEvent {
345 item: ThreadItem {
346 id: item_id,
347 details: ThreadItemDetails::Reasoning(ReasoningItem {
348 text: text.to_string(),
349 stage: self.reasoning_stage.clone(),
350 }),
351 },
352 }));
353 }
354
355 pub fn replace_reasoning_text(&mut self, text: &str) -> bool {
356 replace_stream_text(&mut self.reasoning, text)
357 }
358
359 pub fn append_reasoning_delta(&mut self, delta: &str) -> bool {
360 append_stream_delta(&mut self.reasoning, delta)
361 }
362
363 pub fn set_reasoning_stage(&mut self, stage: Option<String>) -> bool {
364 if self.reasoning_stage == stage {
365 return false;
366 }
367 self.reasoning_stage = stage;
368 true
369 }
370
371 #[must_use]
372 pub fn reasoning_len(&self) -> usize {
373 self.reasoning.text.len()
374 }
375
376 #[must_use]
377 pub fn reasoning_started(&self) -> bool {
378 self.reasoning.started
379 }
380
381 pub fn emit_reasoning_snapshot(&mut self, item_id: Option<String>) -> bool {
382 let item_id = item_id.unwrap_or_else(|| self.next_item_id());
383 let stage = self.reasoning_stage.clone();
384 emit_text_snapshot(
385 &mut self.pending_events,
386 &mut self.reasoning,
387 item_id,
388 move |text| {
389 ThreadItemDetails::Reasoning(ReasoningItem {
390 text,
391 stage: stage.clone(),
392 })
393 },
394 )
395 }
396
397 pub fn emit_reasoning_stage_update(&mut self) -> bool {
398 if !self.reasoning.started {
399 return false;
400 }
401 let Some(item_id) = self.reasoning.item_id.clone() else {
402 return false;
403 };
404 self.pending_events
405 .push(ThreadEvent::ItemUpdated(ItemUpdatedEvent {
406 item: ThreadItem {
407 id: item_id,
408 details: ThreadItemDetails::Reasoning(ReasoningItem {
409 text: self.reasoning.text.clone(),
410 stage: self.reasoning_stage.clone(),
411 }),
412 },
413 }));
414 true
415 }
416
417 pub fn complete_reasoning_stream(&mut self) -> bool {
418 let stage = self.reasoning_stage.clone();
419 complete_text_stream(&mut self.pending_events, &mut self.reasoning, move |text| {
420 ThreadItemDetails::Reasoning(ReasoningItem {
421 text,
422 stage: stage.clone(),
423 })
424 })
425 }
426
427 pub fn start_tool_call(
428 &mut self,
429 call_id: &str,
430 tool_name: Option<String>,
431 item_id: Option<String>,
432 ) -> bool {
433 let generated_item_id = item_id.unwrap_or_else(|| self.next_item_id());
434 let buffer = self
435 .tool_calls
436 .entry(call_id.to_string())
437 .or_insert_with(|| ToolCallStreamState {
438 item_id: generated_item_id,
439 name: None,
440 arguments: String::new(),
441 started: false,
442 });
443
444 if buffer.name.is_none() {
445 buffer.name = tool_name;
446 }
447 if buffer.started {
448 return false;
449 }
450
451 buffer.started = true;
452 self.pending_events.push(tool_started_event(
453 buffer.item_id.clone(),
454 buffer.name.as_deref().unwrap_or_default(),
455 None,
456 Some(call_id),
457 ));
458 true
459 }
460
461 pub fn append_tool_call_delta(
462 &mut self,
463 call_id: &str,
464 delta: &str,
465 tool_name: Option<String>,
466 item_id: Option<String>,
467 ) -> bool {
468 if delta.is_empty() {
469 return false;
470 }
471
472 let generated_item_id = item_id.unwrap_or_else(|| self.next_item_id());
473 let buffer = self
474 .tool_calls
475 .entry(call_id.to_string())
476 .or_insert_with(|| ToolCallStreamState {
477 item_id: generated_item_id,
478 name: None,
479 arguments: String::new(),
480 started: false,
481 });
482
483 if !buffer.started {
484 buffer.started = true;
485 if buffer.name.is_none() {
486 buffer.name = tool_name;
487 }
488 self.pending_events.push(tool_started_event(
489 buffer.item_id.clone(),
490 buffer.name.as_deref().unwrap_or_default(),
491 None,
492 Some(call_id),
493 ));
494 } else if buffer.name.is_none() {
495 buffer.name = tool_name;
496 }
497
498 buffer.arguments.push_str(delta);
499 let arguments = progress_tool_arguments(&buffer.arguments);
500 self.pending_events.push(tool_invocation_updated_event(
501 buffer.item_id.clone(),
502 buffer.name.as_deref().unwrap_or_default(),
503 Some(&arguments),
504 Some(call_id),
505 ToolCallStatus::InProgress,
506 ));
507 true
508 }
509
510 pub fn complete_tool_call(&mut self, call_id: &str, status: ToolCallStatus) -> bool {
511 let Some(buffer) = self.tool_calls.remove(call_id) else {
512 return false;
513 };
514 if !buffer.started {
515 return false;
516 }
517
518 let arguments = if buffer.arguments.is_empty() {
519 None
520 } else {
521 Some(progress_tool_arguments(&buffer.arguments))
522 };
523 self.pending_events.push(tool_invocation_completed_event(
524 buffer.item_id,
525 buffer.name.as_deref().unwrap_or_default(),
526 arguments.as_ref(),
527 Some(call_id),
528 status,
529 ));
530 true
531 }
532
533 #[must_use]
534 pub fn tool_call_item_id(&self, call_id: &str) -> Option<&str> {
535 self.tool_calls
536 .get(call_id)
537 .map(|buffer| buffer.item_id.as_str())
538 }
539
540 pub fn sync_tool_call_arguments(
541 &mut self,
542 call_id: &str,
543 arguments: &str,
544 tool_name: Option<String>,
545 item_id: Option<String>,
546 ) -> bool {
547 let generated_item_id = item_id.unwrap_or_else(|| self.next_item_id());
548 let buffer = self
549 .tool_calls
550 .entry(call_id.to_string())
551 .or_insert_with(|| ToolCallStreamState {
552 item_id: generated_item_id,
553 name: None,
554 arguments: String::new(),
555 started: false,
556 });
557
558 if buffer.name.is_none() {
559 buffer.name = tool_name;
560 }
561
562 if !buffer.started {
563 buffer.started = true;
564 self.pending_events.push(tool_started_event(
565 buffer.item_id.clone(),
566 buffer.name.as_deref().unwrap_or_default(),
567 None,
568 Some(call_id),
569 ));
570 }
571
572 if buffer.arguments == arguments {
573 return false;
574 }
575
576 buffer.arguments.clear();
577 buffer.arguments.push_str(arguments);
578 let args = progress_tool_arguments(&buffer.arguments);
579 self.pending_events.push(tool_invocation_updated_event(
580 buffer.item_id.clone(),
581 buffer.name.as_deref().unwrap_or_default(),
582 Some(&args),
583 Some(call_id),
584 ToolCallStatus::InProgress,
585 ));
586 true
587 }
588
589 pub fn complete_open_items(&mut self) {
590 self.complete_open_text_items();
591 self.complete_open_tool_calls_with_status(ToolCallStatus::Completed);
592 }
593
594 pub fn complete_open_text_items(&mut self) {
595 let _ = self.complete_assistant_stream();
596 let _ = self.complete_reasoning_stream();
597 }
598
599 pub fn complete_open_items_with_tool_status(&mut self, status: ToolCallStatus) {
600 self.complete_open_text_items();
601 self.complete_open_tool_calls_with_status(status);
602 }
603
604 pub fn complete_open_tool_calls_with_status(&mut self, status: ToolCallStatus) {
605 let call_ids = self.tool_calls.keys().cloned().collect::<Vec<_>>();
606 for call_id in call_ids {
607 let _ = self.complete_tool_call(&call_id, status.clone());
608 }
609 }
610
611 #[must_use]
612 pub fn drain_events(&mut self) -> Vec<ThreadEvent> {
613 std::mem::take(&mut self.pending_events)
614 }
615}
616
617fn replace_stream_text(state: &mut StreamingTextState, text: &str) -> bool {
618 if state.text == text {
619 return false;
620 }
621 state.text.clear();
622 state.text.push_str(text);
623 true
624}
625
626fn append_stream_delta(state: &mut StreamingTextState, delta: &str) -> bool {
627 if delta.is_empty() {
628 return false;
629 }
630 state.text.push_str(delta);
631 true
632}
633
634fn emit_text_snapshot(
635 pending_events: &mut Vec<ThreadEvent>,
636 state: &mut StreamingTextState,
637 item_id: String,
638 build_details: impl FnOnce(String) -> ThreadItemDetails,
639) -> bool {
640 if state.text.trim().is_empty() {
641 return false;
642 }
643
644 let item_id = state.item_id.get_or_insert(item_id).clone();
645 let item = ThreadItem {
646 id: item_id,
647 details: build_details(state.text.clone()),
648 };
649
650 if state.started {
651 pending_events.push(ThreadEvent::ItemUpdated(ItemUpdatedEvent { item }));
652 } else {
653 state.started = true;
654 pending_events.push(ThreadEvent::ItemStarted(ItemStartedEvent { item }));
655 }
656 true
657}
658
659fn complete_text_stream(
660 pending_events: &mut Vec<ThreadEvent>,
661 state: &mut StreamingTextState,
662 build_details: impl FnOnce(String) -> ThreadItemDetails,
663) -> bool {
664 if !state.started {
665 return false;
666 }
667
668 let Some(item_id) = state.item_id.take() else {
669 state.started = false;
670 state.text.clear();
671 return false;
672 };
673
674 state.started = false;
675 let text = std::mem::take(&mut state.text);
676 pending_events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent {
677 item: ThreadItem {
678 id: item_id,
679 details: build_details(text),
680 },
681 }));
682 true
683}
684
685#[must_use]
686pub fn tool_output_item_id(call_item_id: &str) -> String {
687 format!("{call_item_id}:output")
688}
689
690fn tool_invocation_item(
691 item_id: String,
692 tool_name: &str,
693 arguments: Option<&Value>,
694 tool_call_id: Option<&str>,
695 status: ToolCallStatus,
696) -> ThreadItem {
697 ThreadItem {
698 id: item_id,
699 details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
700 tool_name: tool_name.to_string(),
701 arguments: arguments.cloned(),
702 tool_call_id: tool_call_id.map(str::to_string),
703 status,
704 }),
705 }
706}
707
708fn tool_output_item(
709 call_item_id: &str,
710 tool_call_id: Option<&str>,
711 status: ToolCallStatus,
712 exit_code: Option<i32>,
713 spool_path: Option<&str>,
714 output: impl Into<String>,
715) -> ThreadItem {
716 ThreadItem {
717 id: tool_output_item_id(call_item_id),
718 details: ThreadItemDetails::ToolOutput(ToolOutputItem {
719 call_id: call_item_id.to_string(),
720 tool_call_id: tool_call_id.map(str::to_string),
721 spool_path: spool_path.map(str::to_string),
722 output: output.into(),
723 exit_code,
724 status,
725 }),
726 }
727}
728
729#[must_use]
730pub fn tool_started_event(
731 item_id: String,
732 tool_name: &str,
733 arguments: Option<&Value>,
734 tool_call_id: Option<&str>,
735) -> ThreadEvent {
736 ThreadEvent::ItemStarted(ItemStartedEvent {
737 item: tool_invocation_item(
738 item_id,
739 tool_name,
740 arguments,
741 tool_call_id,
742 ToolCallStatus::InProgress,
743 ),
744 })
745}
746
747#[must_use]
748pub fn tool_invocation_updated_event(
749 item_id: String,
750 tool_name: &str,
751 arguments: Option<&Value>,
752 tool_call_id: Option<&str>,
753 status: ToolCallStatus,
754) -> ThreadEvent {
755 ThreadEvent::ItemUpdated(ItemUpdatedEvent {
756 item: tool_invocation_item(item_id, tool_name, arguments, tool_call_id, status),
757 })
758}
759
760#[must_use]
761pub fn tool_invocation_completed_event(
762 item_id: String,
763 tool_name: &str,
764 arguments: Option<&Value>,
765 tool_call_id: Option<&str>,
766 status: ToolCallStatus,
767) -> ThreadEvent {
768 ThreadEvent::ItemCompleted(ItemCompletedEvent {
769 item: tool_invocation_item(item_id, tool_name, arguments, tool_call_id, status),
770 })
771}
772
773#[must_use]
774pub fn tool_output_started_event(call_item_id: String, tool_call_id: Option<&str>) -> ThreadEvent {
775 ThreadEvent::ItemStarted(ItemStartedEvent {
776 item: tool_output_item(
777 &call_item_id,
778 tool_call_id,
779 ToolCallStatus::InProgress,
780 None,
781 None,
782 String::new(),
783 ),
784 })
785}
786
787#[must_use]
788pub fn tool_output_updated_event(
789 call_item_id: String,
790 tool_call_id: Option<&str>,
791 output: impl Into<String>,
792) -> ThreadEvent {
793 ThreadEvent::ItemUpdated(ItemUpdatedEvent {
794 item: tool_output_item(
795 &call_item_id,
796 tool_call_id,
797 ToolCallStatus::InProgress,
798 None,
799 None,
800 output,
801 ),
802 })
803}
804
805#[must_use]
806pub fn tool_output_completed_event(
807 call_item_id: String,
808 tool_call_id: Option<&str>,
809 status: ToolCallStatus,
810 exit_code: Option<i32>,
811 spool_path: Option<&str>,
812 output: impl Into<String>,
813) -> ThreadEvent {
814 ThreadEvent::ItemCompleted(ItemCompletedEvent {
815 item: tool_output_item(
816 &call_item_id,
817 tool_call_id,
818 status,
819 exit_code,
820 spool_path,
821 output,
822 ),
823 })
824}
825
826#[must_use]
827#[cold]
828pub fn error_item_completed_event(item_id: String, message: impl Into<String>) -> ThreadEvent {
829 ThreadEvent::ItemCompleted(ItemCompletedEvent {
830 item: ThreadItem {
831 id: item_id,
832 details: ThreadItemDetails::Error(ErrorItem {
833 message: message.into(),
834 }),
835 },
836 })
837}
838
839fn progress_tool_arguments(arguments: &str) -> Value {
840 serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
841}
842
843#[cfg(test)]
844mod tests {
845 use serde_json::json;
846
847 use super::*;
848
849 #[test]
850 fn tool_started_event_omits_arguments_when_absent() {
851 let event = tool_started_event("item".to_string(), "shell", None, Some("call_1"));
852 let ThreadEvent::ItemStarted(ItemStartedEvent { item }) = event else {
853 panic!("expected started item");
854 };
855 let ThreadItemDetails::ToolInvocation(details) = item.details else {
856 panic!("expected tool invocation");
857 };
858 assert!(details.arguments.is_none());
859 assert_eq!(details.tool_name, "shell");
860 }
861
862 #[test]
863 fn tool_output_updated_event_streams_in_progress_output() {
864 let event = tool_output_updated_event("item".to_string(), Some("call_1"), "abc");
865 let ThreadEvent::ItemUpdated(ItemUpdatedEvent { item }) = event else {
866 panic!("expected updated item");
867 };
868 let ThreadItemDetails::ToolOutput(details) = item.details else {
869 panic!("expected tool output");
870 };
871 assert_eq!(details.call_id, "item");
872 assert_eq!(details.tool_call_id.as_deref(), Some("call_1"));
873 assert_eq!(details.output, "abc");
874 assert_eq!(details.status, ToolCallStatus::InProgress);
875 }
876
877 #[test]
878 fn tool_output_payload_preserves_spool_reference() {
879 let payload = tool_output_payload_from_value(&json!({
880 "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
881 "output": "ignored"
882 }));
883
884 assert_eq!(payload.aggregated_output, "");
885 assert_eq!(
886 payload.spool_path.as_deref(),
887 Some(".vtcode/context/tool_outputs/run-1.txt")
888 );
889 }
890
891 #[test]
892 fn tool_output_payload_summarizes_list_results() {
893 let payload = tool_output_payload_from_value(&json!({
894 "items": [
895 {"name": "app.rs", "path": "vtcode-tui/src/app.rs", "type": "file"},
896 {"name": "core_tui", "path": "vtcode-tui/src/core_tui", "type": "directory"},
897 {"name": "lib.rs", "path": "vtcode-tui/src/lib.rs", "type": "file"}
898 ],
899 "count": 3,
900 "total": 11
901 }));
902
903 assert_eq!(payload.spool_path, None);
904 assert!(payload.aggregated_output.contains("Listed 11 items"));
905 assert!(payload.aggregated_output.contains("2 files, 1 directory"));
906 assert!(payload.aggregated_output.contains("vtcode-tui/src/app.rs"));
907 }
908
909 #[test]
910 fn tool_output_payload_combines_list_summary_with_message() {
911 let payload = tool_output_payload_from_value(&json!({
912 "items": [
913 {"name": "app.rs", "path": "vtcode-tui/src/app.rs", "type": "file"},
914 {"name": "core_tui", "path": "vtcode-tui/src/core_tui", "type": "directory"}
915 ],
916 "count": 2,
917 "total": 2,
918 "message": "[+3 more items]"
919 }));
920
921 assert!(payload.aggregated_output.contains("Listed 2 items"));
922 assert!(payload.aggregated_output.contains("[+3 more items]"));
923 }
924
925 #[test]
926 fn tool_output_payload_summarizes_match_results() {
927 let payload = tool_output_payload_from_value(&json!({
928 "matches": [
929 {"path": "src/main.rs", "line_number": 12},
930 {"file": "src/lib.rs", "line_number": 9}
931 ],
932 "total_match_count": 7
933 }));
934
935 assert_eq!(payload.spool_path, None);
936 assert!(payload.aggregated_output.contains("Found 7 matches"));
937 assert!(payload.aggregated_output.contains("src/main.rs"));
938 assert!(payload.aggregated_output.contains("src/lib.rs"));
939 }
940
941 #[test]
942 fn tool_output_payload_summarizes_nested_match_paths() {
943 let payload = tool_output_payload_from_value(&json!({
944 "matches": [
945 {
946 "type": "match",
947 "data": {
948 "path": {"text": "vtcode-tui/src/core_tui/runner/mod.rs"},
949 "line_number": 27,
950 "lines": {"text": "runloop\n"}
951 }
952 }
953 ],
954 "total_match_count": 1
955 }));
956
957 assert!(payload.aggregated_output.contains("Found 1 match"));
958 assert!(
959 payload
960 .aggregated_output
961 .contains("vtcode-tui/src/core_tui/runner/mod.rs")
962 );
963 }
964
965 #[test]
966 fn tool_output_payload_reports_empty_match_set() {
967 let payload = tool_output_payload_from_value(&json!({
968 "matches": [],
969 "path": "vtcode-core/src"
970 }));
971
972 assert_eq!(payload.aggregated_output, "No matches found");
973 assert_eq!(payload.spool_path, None);
974 }
975
976 #[test]
977 fn tool_output_payload_includes_structured_recovery_guidance() {
978 let payload = tool_output_payload_from_value(&json!({
979 "matches": [],
980 "path": "src/agent",
981 "hint": "Pattern looks like a code fragment.",
982 "next_action": "Retry with a larger parseable pattern."
983 }));
984
985 assert!(payload.aggregated_output.contains("No matches found"));
986 assert!(
987 payload
988 .aggregated_output
989 .contains("Pattern looks like a code fragment.")
990 );
991 assert!(
992 payload
993 .aggregated_output
994 .contains("Retry with a larger parseable pattern.")
995 );
996 }
997}