Skip to main content

vtcode_core/core/agent/events/
lifecycle.rs

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/// Shared lifecycle state for assistant text, reasoning, and model-emitted tool calls.
275#[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}