Skip to main content

vtcode_core/core/agent/events/
lifecycle.rs

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    /// Bytes of `arguments` included in the last emitted `item.updated` event.
25    /// Used to throttle per-token delta emissions — see `MIN_TOOL_ARG_UPDATE_BYTES`.
26    last_emitted_args_len: usize,
27    /// Number of intermediate `item.updated` events emitted for this tool call.
28    /// Capped at `MAX_TOOL_ARG_UPDATE_EVENTS` to bound log growth for large arguments.
29    update_events: usize,
30}
31
32/// Minimum accumulated bytes of tool-call arguments between two `item.updated`
33/// events. Without this, every streaming argument delta (one per token) emits a
34/// full-arguments update, producing ~30+ events per tool call and bloating the
35/// session log (observed 3069 `item.updated` events for 98 tool outputs in a
36/// single 3-turn session). The final `complete_tool_call` always emits the full
37/// arguments, so intermediate updates are progress hints only.
38const MIN_TOOL_ARG_UPDATE_BYTES: usize = 512;
39
40/// Maximum intermediate `item.updated` events per tool call. Once reached, no
41/// further streaming updates are emitted until the tool call completes.
42const MAX_TOOL_ARG_UPDATE_EVENTS: usize = 8;
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45/// Aggregated output from a tool execution, containing either inline text or a
46/// spool file reference.
47pub struct ToolOutputPayload {
48    /// Combined output text from the tool execution.
49    pub aggregated_output: String,
50    /// Optional path to a spool file containing the full output.
51    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
58/// Attribute only a successful file tool's recorded result, never current Git state.
59pub 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    // Sample a few hits as `path:line` so archives stay legible without
359    // embedding full snippets.
360    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            // Without an authoritative merged field, stdout and stderr are
461            // separate pipes. Keep both labels even when their bytes happen
462            // to be identical or one is contained by the other.
463            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            // The merged field proves that it contains both named streams as
470            // distinct blocks, so it is safe to emit once without duplicates.
471            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                // Equal or overlapping named streams cannot be deduplicated
487                // from one occurrence in `output`; retain both labels. Keep
488                // the merged value too when neither named stream covers it
489                // completely.
490                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                // The merged value contains the complete named stream and is
500                // the least lossy representation of the result.
501                parts.push(merged.to_string());
502            } else if contains_line_block(named, merged) {
503                // The named stream is the complete value and `output` is a
504                // bounded preview.
505                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        // `content` is an independent payload field. Never replace a labeled
516        // pipe stream with it merely because one contains the other.
517        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            // A string error is rendered below as its user-facing text;
555            // retain structured errors so codes, retry state, and other
556            // diagnostic fields remain available to event consumers.
557            !(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
625/// Extract a [`ToolOutputPayload`] from a tool result JSON value, preserving
626/// bounded metadata alongside a spool reference and falling back to inline
627/// text aggregation otherwise.
628pub 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        // `code_search`-style outputs: without this branch the archive only
675        // records "Structured result with fields: query, filters, results,
676        // returned", which made the turn_912/913 planning trajectories (54
677        // searches) illegible in session archives and ATIF exports.
678        (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/// Shared lifecycle state for assistant text, reasoning, and model-emitted tool calls.
717#[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    /// Generate the next unique item ID for lifecycle events.
729    #[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    /// Emit a completed agent message event with the full text.
737    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    /// Replace the current assistant streaming text. Returns `true` if the text changed.
752    pub fn replace_assistant_text(&mut self, text: &str) -> bool {
753        replace_stream_text(&mut self.assistant, text)
754    }
755
756    /// Whether the assistant text stream has been started.
757    #[must_use]
758    pub fn assistant_started(&self) -> bool {
759        self.assistant.started
760    }
761
762    /// Length of the accumulated assistant text in bytes.
763    #[must_use]
764    pub fn assistant_len(&self) -> usize {
765        self.assistant.text.len()
766    }
767
768    /// Append a delta to the assistant text stream. Returns `true` if content was added.
769    pub fn append_assistant_delta(&mut self, delta: &str) -> bool {
770        append_stream_delta(&mut self.assistant, delta)
771    }
772
773    /// Emit a snapshot of the current assistant text as an item event.
774    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    /// Complete the assistant text stream, emitting a final completed event.
782    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    /// Emit a completed reasoning event with the full text.
789    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    /// Replace the current reasoning streaming text. Returns `true` if the text changed.
807    pub fn replace_reasoning_text(&mut self, text: &str) -> bool {
808        replace_stream_text(&mut self.reasoning, text)
809    }
810
811    /// Append a delta to the reasoning text stream. Returns `true` if content was added.
812    pub fn append_reasoning_delta(&mut self, delta: &str) -> bool {
813        append_stream_delta(&mut self.reasoning, delta)
814    }
815
816    /// Update the reasoning stage label. Returns `true` if the stage changed.
817    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    /// Length of the accumulated reasoning text in bytes.
826    #[must_use]
827    pub fn reasoning_len(&self) -> usize {
828        self.reasoning.text.len()
829    }
830
831    /// Whether the reasoning text stream has been started.
832    #[must_use]
833    pub fn reasoning_started(&self) -> bool {
834        self.reasoning.started
835    }
836
837    /// Emit a snapshot of the current reasoning text as an item event.
838    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    /// Emit an update event reflecting the current reasoning stage.
847    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    /// Complete the reasoning text stream, emitting a final completed event.
868    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    /// Start tracking a tool call, emitting an item-started event.
876    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    /// Append an argument delta to an in-progress tool call.
908    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        // Throttle intermediate `item.updated` events: emit the first delta
949        // eagerly (so the UI shows progress), then only when enough new bytes
950        // have accumulated and the per-call cap hasn't been reached. The final
951        // `complete_tool_call` always emits the full arguments regardless.
952        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    /// Emit a final `item.updated` carrying the full accumulated arguments for
1077    /// each open tool call, bypassing the intermediate-update throttle.
1078    ///
1079    /// Tool calls remain open (not completed); callers complete them via
1080    /// [`Self::complete_tool_call`] when execution finishes. This guarantees
1081    /// the authoritative streamed arguments are visible after streaming ends
1082    /// even when intermediate deltas were throttled (e.g. small tool calls
1083    /// whose completing delta fell below the byte threshold). Updates that
1084    /// would carry the same length as the last emitted snapshot are skipped
1085    /// to avoid redundant events for large tool calls whose last intermediate
1086    /// update already captured the full arguments.
1087    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        // Shape produced by the `code_search` tool (`CodeSearchResult`).
1560        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        // Duplicated path:line sampled once only.
1577        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        // events[0] = tool_started, events[1] = tool_invocation_updated, events[2] = tool_invocation_completed
1755        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        // Clear the ItemStarted event so we only count delta-driven ItemUpdated events.
1770        let _ = emitter.drain_events();
1771
1772        // Send 600 one-byte deltas. The first delta always emits (update_events == 0).
1773        // Subsequent deltas only emit after MIN_TOOL_ARG_UPDATE_BYTES (512) new bytes.
1774        // Expected ItemUpdated events: at 1 byte (first) and at 513 bytes (threshold met).
1775        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        // The complete event must still carry the full accumulated arguments.
1786        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        // Send 5000 one-byte deltas. With MIN_TOOL_ARG_UPDATE_BYTES=512 and
1815        // MAX_TOOL_ARG_UPDATE_EVENTS=8, emissions stop after 8 updates even
1816        // though the threshold keeps being met.
1817        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        // Stream a small JSON tool call in two deltas. The first delta emits
1836        // eagerly (incomplete JSON); the second is below the byte threshold
1837        // and is throttled out.
1838        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        // Flush should emit a final update with the full, valid-JSON arguments.
1845        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        // Tool call should still be open (no completed event).
1866        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        // A single large delta (>= MIN_TOOL_ARG_UPDATE_BYTES) emits with the
1877        // full arguments, so the last_emitted_args_len already equals the
1878        // accumulated length. A subsequent flush should be a no-op.
1879        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}