1use std::collections::HashMap;
2
3use serde::Deserialize;
4
5use crate::model::session::SessionStats;
6
7pub struct ParsedResponse {
9 pub content: String,
11 pub stats: SessionStats,
13}
14
15#[derive(Deserialize)]
17struct ClaudeResponse {
18 result: Option<String>,
19 usage: Option<ClaudeUsage>,
20}
21
22#[derive(Deserialize)]
24struct ClaudeUsage {
25 input_tokens: Option<i64>,
26 output_tokens: Option<i64>,
27}
28
29#[derive(Deserialize)]
31struct GeminiResponse {
32 response: Option<String>,
33 stats: Option<GeminiStats>,
34}
35
36#[derive(Deserialize)]
38struct GeminiStreamEvent {
39 #[serde(rename = "type")]
40 event_type: Option<String>,
41 stats: Option<GeminiStreamResultStats>,
42}
43
44#[derive(Deserialize)]
46struct GeminiStreamResultStats {
47 input_tokens: Option<i64>,
48 output_tokens: Option<i64>,
49}
50
51#[derive(Deserialize)]
53struct GeminiStats {
54 models: Option<HashMap<String, GeminiModelStats>>,
55}
56
57#[derive(Deserialize)]
59struct GeminiModelStats {
60 tokens: Option<GeminiTokens>,
61}
62
63#[derive(Deserialize)]
65struct GeminiTokens {
66 candidates: Option<i64>,
68 input: Option<i64>,
70}
71
72#[derive(Deserialize)]
74struct CodexEvent {
75 #[serde(rename = "type")]
76 event_type: Option<String>,
77 item: Option<CodexItem>,
78 usage: Option<CodexUsage>,
79}
80
81#[derive(Deserialize)]
83struct CodexUsage {
84 input_tokens: Option<i64>,
85 output_tokens: Option<i64>,
86}
87
88#[derive(Deserialize)]
90struct CodexItem {
91 #[serde(rename = "type")]
92 item_type: Option<String>,
93 text: Option<String>,
94 delta: Option<String>,
95}
96
97pub(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
105pub(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
113pub(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
121pub(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
132pub(super) fn parse_antigravity_stream_output_line(_stdout_line: &str) -> Option<(String, bool)> {
138 None
139}
140
141fn 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
196fn 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
219fn 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
253fn 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
265pub(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
283fn 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
333pub(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
351fn 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
400fn 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
418fn 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
443pub(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
522fn 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
531fn 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
549fn 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
556fn 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
583fn 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
600fn 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
636fn 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
656fn 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
689fn 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
706fn 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
800fn 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
837pub 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
852pub 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 let stdout =
887 r#"{"result":"Planned response","usage":{"input_tokens":11,"output_tokens":7}}"#;
888
889 let parsed = parse_claude_response_with_fallback(stdout, "");
891
892 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 fn test_antigravity_parse_response_reads_plain_stdout() {
902 let stdout = r#"{"answer":"Antigravity ok","questions":[],"summary":null}"#;
904 let stderr = "diagnostics";
905
906 let parsed = parse_antigravity_response_with_fallback(stdout, stderr);
908
909 assert_eq!(parsed.content, stdout);
911 assert_eq!(parsed.stats, SessionStats::default());
912 }
913
914 #[test]
915 fn test_antigravity_stream_output_line_is_not_incremental() {
918 let stdout_line = "partial output";
920
921 let parsed_line = parse_antigravity_stream_output_line(stdout_line);
923
924 assert_eq!(parsed_line, None);
926 }
927
928 #[test]
929 fn test_claude_parse_response_uses_stream_message_when_result_is_empty() {
930 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 let parsed = parse_claude_response_with_fallback(stdout, "");
939
940 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 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 let parsed = parse_claude_response_with_fallback(stdout, "");
957
958 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 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 let parsed = parse_claude_response_with_fallback(stdout, "");
971
972 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 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 let parsed_line = parse_claude_stream_output_line(stdout_line);
991
992 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 let stdout_line = r#"{"type":"message","role":"assistant","content":"Partial answer"}"#;
1000
1001 let parsed_line = parse_claude_stream_output_line(stdout_line);
1003
1004 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 let stdout_line = r#"{"type":"result","subtype":"success","result":"Final answer"}"#;
1012
1013 let parsed_line = parse_claude_stream_output_line(stdout_line);
1015
1016 assert_eq!(parsed_line, None);
1018 }
1019
1020 #[test]
1021 fn test_parse_claude_stream_output_line_maps_tool_use_blocks_to_progress() {
1022 let stdout_line = concat!(
1024 r#"{"type":"assistant","message":{"role":"assistant","content":["#,
1025 r#"{"type":"tool_use","name":"Bash"}]}}"#
1026 );
1027
1028 let parsed_line = parse_claude_stream_output_line(stdout_line);
1030
1031 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 let compaction_label = "context_compaction";
1039 let compression_label = "context-compression";
1040
1041 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_eq!(compaction_progress, Some("Compacting context".to_string()));
1047 assert_eq!(compression_progress, Some("Compacting context".to_string()));
1048 }
1049
1050 #[test]
1053 fn test_parse_response_codex_ignores_trailing_completion_status_message() {
1054 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 let parsed = parse_codex_response_with_fallback(stdout, "");
1065
1066 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 fn test_parse_response_codex_prefers_agent_message_over_trailing_reasoning_payload() {
1076 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 let parsed = parse_codex_response_with_fallback(stdout, "");
1087
1088 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 fn test_parse_response_codex_falls_back_to_reasoning_without_agent_message() {
1098 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 let parsed = parse_codex_response_with_fallback(stdout, "");
1107
1108 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 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 let parsed = parse_codex_response_with_fallback(stdout, "");
1127
1128 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 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 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 let assistant_line =
1154 r#"{"type":"item.completed","item":{"type":"agent_message","text":"Final answer"}}"#;
1155
1156 let parsed_line = parse_codex_stream_output_line(assistant_line);
1158
1159 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 let reasoning_line =
1167 r#"{"type":"item.updated","item":{"type":"reasoning","delta":"Inspecting files"}}"#;
1168
1169 let parsed_line = parse_codex_stream_output_line(reasoning_line);
1171
1172 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 let progress = compact_codex_progress_message("context_compaction");
1182
1183 assert_eq!(progress, Some("Compacting context".to_string()));
1185 }
1186}