Skip to main content

ag_agent/agent/
response_parser.rs

1use std::collections::HashMap;
2
3use serde::Deserialize;
4
5use crate::model::session::SessionStats;
6
7/// Parsed agent response including content text and usage statistics.
8pub struct ParsedResponse {
9    /// Final response text from the agent.
10    pub content: String,
11    /// Token usage statistics associated with `content`.
12    pub stats: SessionStats,
13}
14
15/// Claude CLI JSON response shape.
16#[derive(Deserialize)]
17struct ClaudeResponse {
18    result: Option<String>,
19    usage: Option<ClaudeUsage>,
20}
21
22/// Token usage from a Claude CLI response.
23#[derive(Deserialize)]
24struct ClaudeUsage {
25    input_tokens: Option<i64>,
26    output_tokens: Option<i64>,
27}
28
29/// Gemini CLI JSON response shape.
30#[derive(Deserialize)]
31struct GeminiResponse {
32    response: Option<String>,
33    stats: Option<GeminiStats>,
34}
35
36/// Gemini CLI stream event shape.
37#[derive(Deserialize)]
38struct GeminiStreamEvent {
39    #[serde(rename = "type")]
40    event_type: Option<String>,
41    stats: Option<GeminiStreamResultStats>,
42}
43
44/// Token usage from a Gemini stream `result` event.
45#[derive(Deserialize)]
46struct GeminiStreamResultStats {
47    input_tokens: Option<i64>,
48    output_tokens: Option<i64>,
49}
50
51/// Top-level `stats` object from Gemini CLI JSON output.
52#[derive(Deserialize)]
53struct GeminiStats {
54    models: Option<HashMap<String, GeminiModelStats>>,
55}
56
57/// Per-model statistics from Gemini CLI.
58#[derive(Deserialize)]
59struct GeminiModelStats {
60    tokens: Option<GeminiTokens>,
61}
62
63/// Token counts from a single Gemini model.
64#[derive(Deserialize)]
65struct GeminiTokens {
66    /// Output/completion tokens generated by the model.
67    candidates: Option<i64>,
68    /// Uncached prompt tokens (`max(0, prompt - cached)`).
69    input: Option<i64>,
70}
71
72/// Single NDJSON event emitted by Codex CLI (`--json`).
73#[derive(Deserialize)]
74struct CodexEvent {
75    #[serde(rename = "type")]
76    event_type: Option<String>,
77    item: Option<CodexItem>,
78    usage: Option<CodexUsage>,
79}
80
81/// Token usage from a Codex `turn.completed` event.
82#[derive(Deserialize)]
83struct CodexUsage {
84    input_tokens: Option<i64>,
85    output_tokens: Option<i64>,
86}
87
88/// Nested item payload from a Codex stream event.
89#[derive(Deserialize)]
90struct CodexItem {
91    #[serde(rename = "type")]
92    item_type: Option<String>,
93    text: Option<String>,
94    delta: Option<String>,
95}
96
97/// Parses Claude output and falls back to raw output when parsing fails.
98pub(super) fn parse_claude_response_with_fallback(stdout: &str, stderr: &str) -> ParsedResponse {
99    parse_claude_response(stdout).unwrap_or_else(|| ParsedResponse {
100        content: fallback_response(stdout, stderr),
101        stats: SessionStats::default(),
102    })
103}
104
105/// Parses Gemini output and falls back to raw output when parsing fails.
106pub(super) fn parse_gemini_response_with_fallback(stdout: &str, stderr: &str) -> ParsedResponse {
107    parse_gemini_response(stdout).unwrap_or_else(|| ParsedResponse {
108        content: fallback_response(stdout, stderr),
109        stats: SessionStats::default(),
110    })
111}
112
113/// Parses Codex output and falls back to raw output when parsing fails.
114pub(super) fn parse_codex_response_with_fallback(stdout: &str, stderr: &str) -> ParsedResponse {
115    parse_codex_response(stdout).unwrap_or_else(|| ParsedResponse {
116        content: fallback_response(stdout, stderr),
117        stats: SessionStats::default(),
118    })
119}
120
121/// Parses Antigravity output as plain print-mode stdout.
122pub(super) fn parse_antigravity_response_with_fallback(
123    stdout: &str,
124    stderr: &str,
125) -> ParsedResponse {
126    ParsedResponse {
127        content: fallback_response(stdout, stderr),
128        stats: SessionStats::default(),
129    }
130}
131
132/// Parses one Antigravity stream line.
133///
134/// Antigravity print mode currently writes final response text to stdout
135/// without a documented structured stream format, so Agentty suppresses
136/// partial stdout rendering and parses the complete output after exit.
137pub(super) fn parse_antigravity_stream_output_line(_stdout_line: &str) -> Option<(String, bool)> {
138    None
139}
140
141/// Parses one full Claude turn payload from stream-json stdout.
142///
143/// Prefers the final non-empty `result` field when present. When Claude emits
144/// an empty or missing `result` field (observed in some stream-json runs), this
145/// falls back to the latest assistant stream message content so callers still
146/// receive the assistant reply instead of raw NDJSON.
147fn parse_claude_response(stdout: &str) -> Option<ParsedResponse> {
148    let trimmed_stdout = stdout.trim();
149    if trimmed_stdout.is_empty() {
150        return None;
151    }
152
153    let mut latest_result_stats: Option<SessionStats> = None;
154    let mut latest_non_empty_result: Option<ParsedResponse> = None;
155    let mut latest_stream_message_content: Option<String> = None;
156
157    record_claude_complete_payload(
158        trimmed_stdout,
159        &mut latest_result_stats,
160        &mut latest_non_empty_result,
161    );
162
163    for line in stdout.lines() {
164        let trimmed_line = line.trim();
165        let Some(stream_event) = record_claude_line_payload(
166            trimmed_line,
167            &mut latest_result_stats,
168            &mut latest_non_empty_result,
169        ) else {
170            continue;
171        };
172
173        if let Some(stats) = extract_claude_usage_stats(&stream_event) {
174            latest_result_stats = Some(stats);
175        }
176
177        if let Some(content) = extract_claude_stream_assistant_content(&stream_event) {
178            latest_stream_message_content = Some(content);
179        }
180    }
181
182    if let Some(parsed_response) = latest_non_empty_result {
183        return Some(parsed_response);
184    }
185
186    let content = latest_stream_message_content?;
187    if content.trim().is_empty() {
188        return None;
189    }
190
191    let stats = latest_result_stats.unwrap_or_default();
192
193    Some(ParsedResponse { content, stats })
194}
195
196/// Records complete Claude payload formats from one stdout segment.
197fn record_claude_complete_payload(
198    payload: &str,
199    latest_result_stats: &mut Option<SessionStats>,
200    latest_non_empty_result: &mut Option<ParsedResponse>,
201) {
202    if let Some(parsed_response) = parse_claude_response_payload(payload) {
203        record_claude_parsed_response(
204            parsed_response,
205            latest_result_stats,
206            latest_non_empty_result,
207        );
208    }
209
210    if let Some(parsed_response) = parse_claude_response_array_payload(payload) {
211        record_claude_parsed_response(
212            parsed_response,
213            latest_result_stats,
214            latest_non_empty_result,
215        );
216    }
217}
218
219/// Records a Claude line payload and returns stream JSON that still needs
220/// parsing.
221fn record_claude_line_payload(
222    trimmed_line: &str,
223    latest_result_stats: &mut Option<SessionStats>,
224    latest_non_empty_result: &mut Option<ParsedResponse>,
225) -> Option<serde_json::Value> {
226    if trimmed_line.is_empty() {
227        return None;
228    }
229
230    if let Some(parsed_response) = parse_claude_response_payload(trimmed_line) {
231        record_claude_parsed_response(
232            parsed_response,
233            latest_result_stats,
234            latest_non_empty_result,
235        );
236
237        return None;
238    }
239
240    if let Some(parsed_response) = parse_claude_response_array_payload(trimmed_line) {
241        record_claude_parsed_response(
242            parsed_response,
243            latest_result_stats,
244            latest_non_empty_result,
245        );
246
247        return None;
248    }
249
250    serde_json::from_str::<serde_json::Value>(trimmed_line).ok()
251}
252
253/// Updates latest Claude result state from one parsed complete response.
254fn record_claude_parsed_response(
255    parsed_response: ParsedResponse,
256    latest_result_stats: &mut Option<SessionStats>,
257    latest_non_empty_result: &mut Option<ParsedResponse>,
258) {
259    latest_result_stats.replace(parsed_response.stats.clone());
260    if !parsed_response.content.trim().is_empty() {
261        latest_non_empty_result.replace(parsed_response);
262    }
263}
264
265/// Parses one Claude stream line into display text and content/progress type.
266pub(super) fn parse_claude_stream_output_line(stdout_line: &str) -> Option<(String, bool)> {
267    let trimmed_line = stdout_line.trim();
268    if trimmed_line.is_empty() {
269        return None;
270    }
271
272    let stream_event = serde_json::from_str::<serde_json::Value>(trimmed_line).ok()?;
273
274    if let Some(content) = extract_claude_stream_assistant_content(&stream_event) {
275        return Some((content, true));
276    }
277
278    let progress_message = compact_progress_message_from_json(&stream_event)?;
279
280    Some((progress_message, false))
281}
282
283/// Parses Gemini JSON/stream output into final response text and usage.
284fn parse_gemini_response(stdout: &str) -> Option<ParsedResponse> {
285    let trimmed_stdout = stdout.trim();
286    if let Some(parsed_response) = parse_gemini_response_payload(trimmed_stdout) {
287        return Some(parsed_response);
288    }
289
290    let mut latest_legacy_response: Option<ParsedResponse> = None;
291    let mut stream_response = String::new();
292    let mut stream_stats: Option<SessionStats> = None;
293
294    for line in stdout.lines() {
295        let trimmed_line = line.trim();
296        if trimmed_line.is_empty() {
297            continue;
298        }
299
300        if let Some(parsed_response) = parse_gemini_response_payload(trimmed_line) {
301            latest_legacy_response = Some(parsed_response);
302
303            continue;
304        }
305
306        if let Some(stream_chunk) = extract_gemini_stream_response(trimmed_line) {
307            stream_response.push_str(&stream_chunk);
308
309            continue;
310        }
311
312        if let Some(parsed_stream_stats) = extract_gemini_stream_stats(trimmed_line) {
313            stream_stats = Some(parsed_stream_stats);
314        }
315    }
316
317    if let Some(parsed_response) = latest_legacy_response {
318        return Some(parsed_response);
319    }
320
321    if stream_response.is_empty() && stream_stats.is_none() {
322        return None;
323    }
324
325    let stats = stream_stats.unwrap_or_default();
326
327    Some(ParsedResponse {
328        content: stream_response,
329        stats,
330    })
331}
332
333/// Parses one Gemini stream line into display text and content/progress type.
334pub(super) fn parse_gemini_stream_output_line(stdout_line: &str) -> Option<(String, bool)> {
335    let trimmed_line = stdout_line.trim();
336    if trimmed_line.is_empty() {
337        return None;
338    }
339
340    let stream_event = serde_json::from_str::<serde_json::Value>(trimmed_line).ok()?;
341
342    if let Some(content) = extract_gemini_stream_response_from_json(&stream_event) {
343        return Some((content, true));
344    }
345
346    let progress_message = compact_progress_message_from_json(&stream_event)?;
347
348    Some((progress_message, false))
349}
350
351/// Parses Codex NDJSON output into the final assistant message and usage.
352///
353/// Synthetic completion-status `agent_message` events are ignored so trailing
354/// status lines do not replace the assistant-authored reply. When both
355/// `agent_message` and `reasoning`/`thought` items are present, this parser
356/// always prefers `agent_message` content for the final response so internal
357/// reasoning payloads (including raw JSON snippets) do not overwrite
358/// user-visible assistant text.
359fn parse_codex_response(stdout: &str) -> Option<ParsedResponse> {
360    let mut last_agent_message: Option<String> = None;
361    let mut last_reasoning_message: Option<String> = None;
362    let mut total_input_tokens: u64 = 0;
363    let mut total_output_tokens: u64 = 0;
364
365    for line in stdout.lines() {
366        let trimmed_line = line.trim();
367        if trimmed_line.is_empty() {
368            continue;
369        }
370
371        let Ok(event) = serde_json::from_str::<CodexEvent>(trimmed_line) else {
372            continue;
373        };
374
375        record_codex_turn_usage(&event, &mut total_input_tokens, &mut total_output_tokens);
376
377        if event.event_type.as_deref() != Some("item.completed") {
378            continue;
379        }
380
381        let Some(item) = event.item else {
382            continue;
383        };
384
385        record_codex_completed_item(item, &mut last_agent_message, &mut last_reasoning_message);
386    }
387
388    let stats = SessionStats {
389        added_lines: 0,
390        deleted_lines: 0,
391        input_tokens: total_input_tokens,
392        output_tokens: total_output_tokens,
393    };
394
395    last_agent_message
396        .or(last_reasoning_message)
397        .map(|content| ParsedResponse { content, stats })
398}
399
400/// Adds token usage from one Codex turn completion event.
401fn record_codex_turn_usage(
402    event: &CodexEvent,
403    total_input_tokens: &mut u64,
404    total_output_tokens: &mut u64,
405) {
406    if event.event_type.as_deref() != Some("turn.completed") {
407        return;
408    }
409
410    let Some(usage) = event.usage.as_ref() else {
411        return;
412    };
413
414    *total_input_tokens += usage.input_tokens.unwrap_or(0).cast_unsigned();
415    *total_output_tokens += usage.output_tokens.unwrap_or(0).cast_unsigned();
416}
417
418/// Records visible content from one completed Codex item event.
419fn record_codex_completed_item(
420    item: CodexItem,
421    last_agent_message: &mut Option<String>,
422    last_reasoning_message: &mut Option<String>,
423) {
424    let Some(text) = item.text.or(item.delta) else {
425        return;
426    };
427    if is_codex_completion_status_message(&text) {
428        return;
429    }
430
431    let item_type = item.item_type.as_deref().unwrap_or_default();
432    if item_type == "agent_message" || item_type == "agentmessage" {
433        last_agent_message.replace(text);
434
435        return;
436    }
437
438    if item_type == "reasoning" || item_type == "thought" {
439        last_reasoning_message.replace(text);
440    }
441}
442
443/// Parses one Codex stream line into display text and content/progress type.
444pub(super) fn parse_codex_stream_output_line(stdout_line: &str) -> Option<(String, bool)> {
445    let trimmed_line = stdout_line.trim();
446    if trimmed_line.is_empty() {
447        return None;
448    }
449
450    let event = serde_json::from_str::<CodexEvent>(trimmed_line).ok()?;
451    let event_type = event.event_type.as_deref();
452    if event_type == Some("item.started") {
453        let item = event.item.as_ref()?;
454        let item_type = item.item_type.as_deref()?;
455        let progress_message = compact_codex_progress_message(item_type)?;
456
457        return Some((progress_message, false));
458    }
459
460    if event_type == Some("item.updated") {
461        let item = event.item.as_ref()?;
462        let item_type = item.item_type.as_deref()?;
463        if item_type == "agent_message"
464            || item_type == "agentmessage"
465            || item_type == "reasoning"
466            || item_type == "thought"
467        {
468            let delta = item.delta.as_ref().or(item.text.as_ref())?.clone();
469            let is_response_content = item_type == "agent_message" || item_type == "agentmessage";
470
471            return Some((delta, is_response_content));
472        }
473    }
474
475    if event_type != Some("item.completed") {
476        return None;
477    }
478
479    let item = event.item?;
480    let item_type = item.item_type.as_deref()?;
481    if !(item_type == "agent_message"
482        || item_type == "agentmessage"
483        || item_type == "reasoning"
484        || item_type == "thought")
485    {
486        return None;
487    }
488
489    let text = item.text?;
490    if is_codex_completion_status_message(&text) {
491        return None;
492    }
493
494    let is_response_content = item_type == "agent_message" || item_type == "agentmessage";
495
496    Some((text, is_response_content))
497}
498
499fn parse_claude_response_payload(stdout: &str) -> Option<ParsedResponse> {
500    let response = serde_json::from_str::<ClaudeResponse>(stdout).ok()?;
501    let content = response.result?;
502    let stats = SessionStats {
503        added_lines: 0,
504        deleted_lines: 0,
505        input_tokens: response
506            .usage
507            .as_ref()
508            .and_then(|usage| usage.input_tokens)
509            .unwrap_or(0)
510            .cast_unsigned(),
511        output_tokens: response
512            .usage
513            .as_ref()
514            .and_then(|usage| usage.output_tokens)
515            .unwrap_or(0)
516            .cast_unsigned(),
517    };
518
519    Some(ParsedResponse { content, stats })
520}
521
522/// Parses one legacy Gemini JSON response payload.
523fn parse_gemini_response_payload(stdout: &str) -> Option<ParsedResponse> {
524    let response = serde_json::from_str::<GeminiResponse>(stdout).ok()?;
525    let content = response.response?;
526    let stats = extract_gemini_stats(response.stats.as_ref());
527
528    Some(ParsedResponse { content, stats })
529}
530
531/// Extracts aggregate Gemini token usage from the first reported model entry.
532fn extract_gemini_stats(stats: Option<&GeminiStats>) -> SessionStats {
533    let Some(tokens) = stats
534        .and_then(|stats| stats.models.as_ref())
535        .and_then(|models| models.values().next())
536        .and_then(|model| model.tokens.as_ref())
537    else {
538        return SessionStats::default();
539    };
540
541    SessionStats {
542        added_lines: 0,
543        deleted_lines: 0,
544        input_tokens: tokens.input.unwrap_or(0).cast_unsigned(),
545        output_tokens: tokens.candidates.unwrap_or(0).cast_unsigned(),
546    }
547}
548
549/// Extracts assistant text from one Gemini stream-json line.
550fn extract_gemini_stream_response(stdout_line: &str) -> Option<String> {
551    let stream_event = serde_json::from_str::<serde_json::Value>(stdout_line).ok()?;
552
553    extract_gemini_stream_response_from_json(&stream_event)
554}
555
556/// Extracts assistant text from one parsed Gemini stream event.
557fn extract_gemini_stream_response_from_json(stream_event: &serde_json::Value) -> Option<String> {
558    if let Some(response) = stream_event
559        .get("response")
560        .and_then(serde_json::Value::as_str)
561        && !response.is_empty()
562    {
563        return Some(response.to_string());
564    }
565
566    let event_type = stream_event
567        .get("type")
568        .and_then(serde_json::Value::as_str)
569        .unwrap_or_default();
570    if !matches!(event_type, "content" | "delta" | "message" | "response") {
571        return None;
572    }
573
574    stream_event
575        .get("text")
576        .or_else(|| stream_event.get("delta"))
577        .or_else(|| stream_event.get("content"))
578        .and_then(serde_json::Value::as_str)
579        .filter(|text| !text.is_empty())
580        .map(ToString::to_string)
581}
582
583/// Extracts usage stats from one Gemini stream-json result line.
584fn extract_gemini_stream_stats(stdout_line: &str) -> Option<SessionStats> {
585    let stream_event = serde_json::from_str::<GeminiStreamEvent>(stdout_line).ok()?;
586    if stream_event.event_type.as_deref() != Some("result") {
587        return None;
588    }
589
590    let stats = stream_event.stats?;
591
592    Some(SessionStats {
593        added_lines: 0,
594        deleted_lines: 0,
595        input_tokens: stats.input_tokens.unwrap_or(0).cast_unsigned(),
596        output_tokens: stats.output_tokens.unwrap_or(0).cast_unsigned(),
597    })
598}
599
600/// Parses one Claude JSON-array payload and extracts the best assistant
601/// result text and usage stats.
602///
603/// Newer CLI variants can emit one JSON array containing init/assistant/result
604/// events rather than one flat object. This parser keeps the latest non-empty
605/// `result` string when present, otherwise falls back to assistant message
606/// text.
607fn parse_claude_response_array_payload(stdout: &str) -> Option<ParsedResponse> {
608    let events = serde_json::from_str::<serde_json::Value>(stdout).ok()?;
609    let events = events.as_array()?;
610    let mut latest_result_content: Option<String> = None;
611    let mut latest_assistant_content: Option<String> = None;
612    let mut latest_stats: Option<SessionStats> = None;
613
614    for event in events {
615        if let Some(stats) = extract_claude_usage_stats(event) {
616            latest_stats = Some(stats);
617        }
618
619        if let Some(result_content) = event.get("result").and_then(serde_json::Value::as_str)
620            && !result_content.trim().is_empty()
621        {
622            latest_result_content = Some(result_content.to_string());
623        }
624
625        if let Some(assistant_content) = extract_claude_stream_assistant_content(event) {
626            latest_assistant_content = Some(assistant_content);
627        }
628    }
629
630    let content = latest_result_content.or(latest_assistant_content)?;
631    let stats = latest_stats.unwrap_or_default();
632
633    Some(ParsedResponse { content, stats })
634}
635
636/// Extracts Claude usage statistics from one JSON stream event.
637fn extract_claude_usage_stats(stream_event: &serde_json::Value) -> Option<SessionStats> {
638    let usage = stream_event.get("usage")?;
639
640    Some(SessionStats {
641        added_lines: 0,
642        deleted_lines: 0,
643        input_tokens: usage
644            .get("input_tokens")
645            .and_then(serde_json::Value::as_i64)
646            .unwrap_or(0)
647            .cast_unsigned(),
648        output_tokens: usage
649            .get("output_tokens")
650            .and_then(serde_json::Value::as_i64)
651            .unwrap_or(0)
652            .cast_unsigned(),
653    })
654}
655
656/// Extracts assistant text chunks from one Claude `stream-json` line.
657///
658/// Supports both legacy `{"type":"assistant","message":...}` events and newer
659/// `{"type":"message",...}` assistant events. Text blocks are joined into one
660/// displayable chunk.
661fn extract_claude_stream_assistant_content(stream_event: &serde_json::Value) -> Option<String> {
662    let message = extract_claude_stream_assistant_message(stream_event)?;
663    if message.get("role").and_then(serde_json::Value::as_str) != Some("assistant") {
664        return None;
665    }
666
667    if let Some(content) = message.get("content").and_then(serde_json::Value::as_str) {
668        let trimmed_content = content.trim();
669        if !trimmed_content.is_empty() {
670            return Some(trimmed_content.to_string());
671        }
672    }
673
674    let content_blocks = message
675        .get("content")
676        .and_then(serde_json::Value::as_array)?;
677    let text = content_blocks
678        .iter()
679        .filter_map(extract_claude_stream_content_block_text)
680        .collect::<Vec<_>>()
681        .join("");
682    if text.is_empty() {
683        return None;
684    }
685
686    Some(text)
687}
688
689/// Resolves the assistant message payload from one Claude stream event.
690///
691/// Claude has emitted both nested assistant events (`type=assistant`) and
692/// direct message events (`type=message`) across releases.
693fn extract_claude_stream_assistant_message(
694    stream_event: &serde_json::Value,
695) -> Option<&serde_json::Value> {
696    let event_type = stream_event
697        .get("type")
698        .and_then(serde_json::Value::as_str)?;
699    match event_type {
700        "assistant" => stream_event.get("message"),
701        "message" => Some(stream_event),
702        _ => None,
703    }
704}
705
706/// Returns text from one Claude assistant content block when present.
707///
708/// Supports both direct text blocks and nested `delta.text` payloads used by
709/// partial streaming updates.
710fn extract_claude_stream_content_block_text(content_block: &serde_json::Value) -> Option<&str> {
711    let block_type = content_block
712        .get("type")
713        .and_then(serde_json::Value::as_str)?;
714    if block_type != "text" && block_type != "text_delta" {
715        return None;
716    }
717
718    if let Some(text) = content_block
719        .get("text")
720        .and_then(serde_json::Value::as_str)
721    {
722        return Some(text);
723    }
724
725    content_block
726        .get("delta")
727        .and_then(|delta| delta.get("text"))
728        .and_then(serde_json::Value::as_str)
729}
730
731fn compact_progress_message_from_json(stream_event: &serde_json::Value) -> Option<String> {
732    let assistant_content_type = stream_event
733        .get("message")
734        .and_then(|message| message.get("content"))
735        .and_then(serde_json::Value::as_array)
736        .and_then(|content| {
737            content.iter().find_map(|content_block| {
738                content_block
739                    .get("type")
740                    .and_then(serde_json::Value::as_str)
741            })
742        });
743    if let Some(progress_message) =
744        assistant_content_type.and_then(compact_progress_message_from_stream_label)
745    {
746        return Some(progress_message);
747    }
748
749    let item_type = stream_event
750        .get("item")
751        .and_then(|item| item.get("type"))
752        .and_then(serde_json::Value::as_str);
753    if let Some(progress_message) = item_type.and_then(compact_progress_message_from_stream_label) {
754        return Some(progress_message);
755    }
756
757    let tool_name = stream_event
758        .get("tool_name")
759        .and_then(serde_json::Value::as_str);
760    if let Some(progress_message) = tool_name.and_then(compact_progress_message_from_stream_label) {
761        return Some(progress_message);
762    }
763
764    let name = stream_event.get("name").and_then(serde_json::Value::as_str);
765    if let Some(progress_message) = name.and_then(compact_progress_message_from_stream_label) {
766        return Some(progress_message);
767    }
768
769    let tool_name = stream_event
770        .get("tool")
771        .and_then(|tool| tool.get("name"))
772        .and_then(serde_json::Value::as_str);
773    if let Some(progress_message) = tool_name.and_then(compact_progress_message_from_stream_label) {
774        return Some(progress_message);
775    }
776
777    let event = stream_event
778        .get("event")
779        .and_then(serde_json::Value::as_str);
780    if let Some(progress_message) = event.and_then(compact_progress_message_from_stream_label) {
781        return Some(progress_message);
782    }
783
784    let subtype = stream_event
785        .get("subtype")
786        .and_then(serde_json::Value::as_str);
787    if let Some(progress_message) = subtype.and_then(compact_progress_message_from_stream_label) {
788        return Some(progress_message);
789    }
790
791    let event_type = stream_event.get("type").and_then(serde_json::Value::as_str);
792    if let Some(progress_message) = event_type.and_then(compact_progress_message_from_stream_label)
793    {
794        return Some(progress_message);
795    }
796
797    None
798}
799
800/// Maps one provider stream label to a compact user-facing progress message.
801///
802/// This intentionally recognizes broad families of labels because provider
803/// stream payloads evolve over time and may rename internal phases while still
804/// describing the same user-visible activity.
805fn compact_progress_message_from_stream_label(label: &str) -> Option<String> {
806    let normalized_label = label.to_ascii_lowercase().replace('-', "_");
807    if normalized_label.contains("search") {
808        return Some("Searching the web".to_string());
809    }
810
811    if normalized_label.contains("compact") || normalized_label.contains("compress") {
812        return Some("Compacting context".to_string());
813    }
814
815    if normalized_label.contains("reasoning")
816        || normalized_label.contains("thinking")
817        || normalized_label.contains("thought")
818    {
819        return Some("Thinking".to_string());
820    }
821
822    if normalized_label.contains("command")
823        || normalized_label.contains("bash")
824        || normalized_label.contains("terminal")
825        || normalized_label.contains("shell")
826        || normalized_label.contains("tool_use")
827        || normalized_label.contains("tool_call")
828        || normalized_label.contains("toolcall")
829        || normalized_label.contains("execute")
830    {
831        return Some("Running a command".to_string());
832    }
833
834    None
835}
836
837/// Maps a Codex item type to a compact progress message for the UI.
838///
839/// Returns `None` for `agent_message` since it carries response content, not
840/// progress.
841pub fn compact_codex_progress_message(item_type: &str) -> Option<String> {
842    match item_type {
843        "agent_message" => None,
844        "command_execution" => Some("Running a command".to_string()),
845        "context_compaction" => Some("Compacting context".to_string()),
846        "reasoning" | "thought" => Some("Thinking".to_string()),
847        "web_search" => Some("Searching the web".to_string()),
848        other => Some(format!("Working: {}", other.replace('_', " "))),
849    }
850}
851
852/// Returns whether a Codex `agent_message` is a synthetic completion status.
853///
854/// These short status lines are emitted for internal tool/reasoning steps and
855/// should not be rendered as chat content.
856pub fn is_codex_completion_status_message(message: &str) -> bool {
857    let normalized_message = message.trim().trim_end_matches('.').to_ascii_lowercase();
858
859    matches!(
860        normalized_message.as_str(),
861        "command completed"
862            | "command execution completed"
863            | "thinking completed"
864            | "reasoning completed"
865            | "search completed"
866            | "web search completed"
867    )
868}
869
870fn fallback_response(stdout: &str, stderr: &str) -> String {
871    let trimmed_stdout = stdout.trim();
872    if trimmed_stdout.is_empty() {
873        return stderr.trim().to_string();
874    }
875
876    trimmed_stdout.to_string()
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882
883    #[test]
884    fn test_claude_parse_response_reads_result_payload() {
885        // Arrange
886        let stdout =
887            r#"{"result":"Planned response","usage":{"input_tokens":11,"output_tokens":7}}"#;
888
889        // Act
890        let parsed = parse_claude_response_with_fallback(stdout, "");
891
892        // Assert
893        assert_eq!(parsed.content, "Planned response");
894        assert_eq!(parsed.stats.input_tokens, 11);
895        assert_eq!(parsed.stats.output_tokens, 7);
896    }
897
898    #[test]
899    /// Ensures Antigravity print-mode responses are parsed from stdout as
900    /// complete final content.
901    fn test_antigravity_parse_response_reads_plain_stdout() {
902        // Arrange
903        let stdout = r#"{"answer":"Antigravity ok","questions":[],"summary":null}"#;
904        let stderr = "diagnostics";
905
906        // Act
907        let parsed = parse_antigravity_response_with_fallback(stdout, stderr);
908
909        // Assert
910        assert_eq!(parsed.content, stdout);
911        assert_eq!(parsed.stats, SessionStats::default());
912    }
913
914    #[test]
915    /// Ensures Antigravity stream parsing waits for the final print-mode
916    /// output instead of treating stdout lines as incremental transcript.
917    fn test_antigravity_stream_output_line_is_not_incremental() {
918        // Arrange
919        let stdout_line = "partial output";
920
921        // Act
922        let parsed_line = parse_antigravity_stream_output_line(stdout_line);
923
924        // Assert
925        assert_eq!(parsed_line, None);
926    }
927
928    #[test]
929    fn test_claude_parse_response_uses_stream_message_when_result_is_empty() {
930        // Arrange
931        let stdout = concat!(
932            r#"{"type":"message","role":"assistant","content":"Recovered response payload"}"#,
933            "\n",
934            r#"{"type":"result","subtype":"success","result":"","usage":{"input_tokens":11,"output_tokens":7}}"#
935        );
936
937        // Act
938        let parsed = parse_claude_response_with_fallback(stdout, "");
939
940        // Assert
941        assert_eq!(parsed.content, "Recovered response payload");
942        assert_eq!(parsed.stats.input_tokens, 11);
943        assert_eq!(parsed.stats.output_tokens, 7);
944    }
945
946    #[test]
947    fn test_claude_parse_response_reads_usage_when_result_is_missing() {
948        // Arrange
949        let stdout = concat!(
950            r#"{"type":"message","role":"assistant","content":"Recovered response payload"}"#,
951            "\n",
952            r#"{"type":"result","subtype":"success","usage":{"input_tokens":13,"output_tokens":5}}"#
953        );
954
955        // Act
956        let parsed = parse_claude_response_with_fallback(stdout, "");
957
958        // Assert
959        assert_eq!(parsed.content, "Recovered response payload");
960        assert_eq!(parsed.stats.input_tokens, 13);
961        assert_eq!(parsed.stats.output_tokens, 5);
962    }
963
964    #[test]
965    fn test_claude_parse_response_reads_result_from_json_array_payload() {
966        // Arrange
967        let stdout = r#"[{"type":"system","subtype":"init"},{"type":"result","subtype":"success","result":"{\"answer\":\"Recovered payload\",\"questions\":[],\"summary\":null}","usage":{"input_tokens":5,"output_tokens":3}}]"#;
968
969        // Act
970        let parsed = parse_claude_response_with_fallback(stdout, "");
971
972        // Assert
973        assert_eq!(
974            parsed.content,
975            r#"{"answer":"Recovered payload","questions":[],"summary":null}"#
976        );
977        assert_eq!(parsed.stats.input_tokens, 5);
978        assert_eq!(parsed.stats.output_tokens, 3);
979    }
980
981    #[test]
982    fn test_parse_claude_stream_output_line_reads_assistant_message_content_blocks() {
983        // Arrange
984        let stdout_line = concat!(
985            r#"{"type":"assistant","message":{"role":"assistant","content":["#,
986            r#"{"type":"text","text":"Partial "},{"type":"text","text":"answer"}]}}"#
987        );
988
989        // Act
990        let parsed_line = parse_claude_stream_output_line(stdout_line);
991
992        // Assert
993        assert_eq!(parsed_line, Some(("Partial answer".to_string(), true)));
994    }
995
996    #[test]
997    fn test_parse_claude_stream_output_line_reads_message_event_content() {
998        // Arrange
999        let stdout_line = r#"{"type":"message","role":"assistant","content":"Partial answer"}"#;
1000
1001        // Act
1002        let parsed_line = parse_claude_stream_output_line(stdout_line);
1003
1004        // Assert
1005        assert_eq!(parsed_line, Some(("Partial answer".to_string(), true)));
1006    }
1007
1008    #[test]
1009    fn test_parse_claude_stream_output_line_ignores_result_events_for_delta_streaming() {
1010        // Arrange
1011        let stdout_line = r#"{"type":"result","subtype":"success","result":"Final answer"}"#;
1012
1013        // Act
1014        let parsed_line = parse_claude_stream_output_line(stdout_line);
1015
1016        // Assert
1017        assert_eq!(parsed_line, None);
1018    }
1019
1020    #[test]
1021    fn test_parse_claude_stream_output_line_maps_tool_use_blocks_to_progress() {
1022        // Arrange
1023        let stdout_line = concat!(
1024            r#"{"type":"assistant","message":{"role":"assistant","content":["#,
1025            r#"{"type":"tool_use","name":"Bash"}]}}"#
1026        );
1027
1028        // Act
1029        let parsed_line = parse_claude_stream_output_line(stdout_line);
1030
1031        // Assert
1032        assert_eq!(parsed_line, Some(("Running a command".to_string(), false)));
1033    }
1034
1035    #[test]
1036    fn test_compact_progress_message_from_stream_label_maps_compaction_labels() {
1037        // Arrange
1038        let compaction_label = "context_compaction";
1039        let compression_label = "context-compression";
1040
1041        // Act
1042        let compaction_progress = compact_progress_message_from_stream_label(compaction_label);
1043        let compression_progress = compact_progress_message_from_stream_label(compression_label);
1044
1045        // Assert
1046        assert_eq!(compaction_progress, Some("Compacting context".to_string()));
1047        assert_eq!(compression_progress, Some("Compacting context".to_string()));
1048    }
1049
1050    /// Ensures final NDJSON parsing keeps the real assistant reply when
1051    /// completion-status messages trail the stream.
1052    #[test]
1053    fn test_parse_response_codex_ignores_trailing_completion_status_message() {
1054        // Arrange
1055        let stdout = concat!(
1056            r#"{"type":"item.completed","item":{"type":"agent_message","text":"Planned final answer"}}"#,
1057            "\n",
1058            r#"{"type":"turn.completed","usage":{"input_tokens":21,"output_tokens":8}}"#,
1059            "\n",
1060            r#"{"type":"item.completed","item":{"type":"agent_message","text":"Command completed"}}"#,
1061        );
1062
1063        // Act
1064        let parsed = parse_codex_response_with_fallback(stdout, "");
1065
1066        // Assert
1067        assert_eq!(parsed.content, "Planned final answer");
1068        assert_eq!(parsed.stats.input_tokens, 21);
1069        assert_eq!(parsed.stats.output_tokens, 8);
1070    }
1071
1072    #[test]
1073    /// Ensures trailing reasoning payloads do not overwrite final
1074    /// `agent_message` output.
1075    fn test_parse_response_codex_prefers_agent_message_over_trailing_reasoning_payload() {
1076        // Arrange
1077        let stdout = concat!(
1078            r#"{"type":"item.completed","item":{"type":"agent_message","text":"Focused review summary"}}"#,
1079            "\n",
1080            r#"{"type":"item.completed","item":{"type":"reasoning","text":"{\"title\":\"Inject clock dependency to enforce time boundaries\"}"}}"#,
1081            "\n",
1082            r#"{"type":"turn.completed","usage":{"input_tokens":13,"output_tokens":9}}"#,
1083        );
1084
1085        // Act
1086        let parsed = parse_codex_response_with_fallback(stdout, "");
1087
1088        // Assert
1089        assert_eq!(parsed.content, "Focused review summary");
1090        assert_eq!(parsed.stats.input_tokens, 13);
1091        assert_eq!(parsed.stats.output_tokens, 9);
1092    }
1093
1094    #[test]
1095    /// Ensures parsing still returns reasoning text when no
1096    /// `agent_message` item exists.
1097    fn test_parse_response_codex_falls_back_to_reasoning_without_agent_message() {
1098        // Arrange
1099        let stdout = concat!(
1100            r#"{"type":"item.completed","item":{"type":"reasoning","text":"Fallback reasoning output"}}"#,
1101            "\n",
1102            r#"{"type":"turn.completed","usage":{"input_tokens":5,"output_tokens":3}}"#,
1103        );
1104
1105        // Act
1106        let parsed = parse_codex_response_with_fallback(stdout, "");
1107
1108        // Assert
1109        assert_eq!(parsed.content, "Fallback reasoning output");
1110        assert_eq!(parsed.stats.input_tokens, 5);
1111        assert_eq!(parsed.stats.output_tokens, 3);
1112    }
1113
1114    #[test]
1115    fn test_parse_response_codex_ignores_completion_status_as_final_message() {
1116        // Arrange
1117        let stdout = concat!(
1118            r#"{"type":"item.completed","item":{"type":"agent_message","text":"Final answer"}}"#,
1119            "\n",
1120            r#"{"type":"item.completed","item":{"type":"agent_message","text":"Command completed"}}"#,
1121            "\n",
1122            r#"{"type":"turn.completed","usage":{"input_tokens":13,"output_tokens":9}}"#,
1123        );
1124
1125        // Act
1126        let parsed = parse_codex_response_with_fallback(stdout, "");
1127
1128        // Assert
1129        assert_eq!(parsed.content, "Final answer");
1130        assert_eq!(parsed.stats.input_tokens, 13);
1131        assert_eq!(parsed.stats.output_tokens, 9);
1132    }
1133
1134    #[test]
1135    fn test_parse_stream_output_line_codex_ignores_completion_status_messages() {
1136        // Arrange
1137        let completion_status_lines = [
1138            r#"{"type":"item.completed","item":{"type":"agent_message","text":"Command completed"}}"#,
1139            r#"{"type":"item.completed","item":{"type":"agent_message","text":"Thinking completed"}}"#,
1140        ];
1141
1142        // Act & Assert
1143        for completion_status_line in completion_status_lines {
1144            let parsed_line = parse_codex_stream_output_line(completion_status_line);
1145
1146            assert_eq!(parsed_line, None);
1147        }
1148    }
1149
1150    #[test]
1151    fn test_parse_stream_output_line_codex_keeps_assistant_message_content() {
1152        // Arrange
1153        let assistant_line =
1154            r#"{"type":"item.completed","item":{"type":"agent_message","text":"Final answer"}}"#;
1155
1156        // Act
1157        let parsed_line = parse_codex_stream_output_line(assistant_line);
1158
1159        // Assert
1160        assert_eq!(parsed_line, Some(("Final answer".to_string(), true)));
1161    }
1162
1163    #[test]
1164    fn test_parse_stream_output_line_codex_marks_reasoning_as_loader_text() {
1165        // Arrange
1166        let reasoning_line =
1167            r#"{"type":"item.updated","item":{"type":"reasoning","delta":"Inspecting files"}}"#;
1168
1169        // Act
1170        let parsed_line = parse_codex_stream_output_line(reasoning_line);
1171
1172        // Assert
1173        assert_eq!(parsed_line, Some(("Inspecting files".to_string(), false)));
1174    }
1175
1176    #[test]
1177    fn compact_codex_progress_message_returns_compacting_for_context_compaction() {
1178        // Arrange
1179
1180        // Act
1181        let progress = compact_codex_progress_message("context_compaction");
1182
1183        // Assert
1184        assert_eq!(progress, Some("Compacting context".to_string()));
1185    }
1186}