1use std::os::unix::process::ExitStatusExt as _;
7use std::sync::{Arc, Mutex};
8
9use tokio::io::AsyncBufReadExt as _;
10use tokio::sync::mpsc;
11
12use crate::agent::cli::{error, stdin};
13use crate::agent::{self as agent, AgentBackend, BuildCommandRequest};
14use crate::channel::{
15 AgentChannel, AgentError, AgentFuture, SessionRef, StartSessionRequest, TurnEvent, TurnPrompt,
16 TurnRequest, TurnResult,
17};
18use crate::model::agent::AgentKind;
19
20pub struct CliAgentChannel {
28 backend: Arc<dyn AgentBackend>,
30 kind: AgentKind,
32}
33
34impl CliAgentChannel {
35 pub fn new(kind: AgentKind) -> Self {
37 let backend = Arc::from(agent::create_backend(kind));
38
39 Self { backend, kind }
40 }
41
42 pub fn with_backend(backend: Arc<dyn agent::AgentBackend>, kind: AgentKind) -> Self {
49 Self { backend, kind }
50 }
51}
52
53fn build_command_request<'a>(
55 request: &'a TurnRequest,
56 prompt_text: &'a str,
57) -> BuildCommandRequest<'a> {
58 BuildCommandRequest {
59 attachments: &request.prompt.attachments,
60 folder: &request.folder,
61 prompt: prompt_text,
62 request_kind: &request.request_kind,
63 model: &request.model,
64 reasoning_level: request.reasoning_level,
65 }
66}
67
68impl AgentChannel for CliAgentChannel {
69 fn start_session(
71 &self,
72 req: StartSessionRequest,
73 ) -> AgentFuture<Result<SessionRef, AgentError>> {
74 let session_id = req.session_id;
75
76 Box::pin(async move { Ok(SessionRef { session_id }) })
77 }
78
79 fn run_turn(
91 &self,
92 _session_id: String,
93 req: TurnRequest,
94 events: mpsc::UnboundedSender<TurnEvent>,
95 ) -> AgentFuture<Result<TurnResult, AgentError>> {
96 let kind = self.kind;
97 let backend = Arc::clone(&self.backend);
98
99 Box::pin(async move {
100 let prompt_text = req.prompt.agent_text();
101 let build_request = build_command_request(&req, &prompt_text);
102 let build_result = backend.build_command(build_request);
103 let stdin_payload_result = agent::build_command_stdin_payload(kind, build_request);
104 let command = build_result.map_err(|error| {
105 AgentError::Backend(format!("Failed to build command: {error}"))
106 })?;
107 let stdin_payload = stdin_payload_result.map_err(|error| {
108 AgentError::Backend(format!("Failed to build command stdin payload: {error}"))
109 })?;
110
111 let mut tokio_cmd = tokio::process::Command::from(command);
112 tokio_cmd.stdin(if stdin_payload.is_some() {
113 std::process::Stdio::piped()
114 } else {
115 std::process::Stdio::null()
116 });
117 tokio_cmd.stdout(std::process::Stdio::piped());
118 tokio_cmd.stderr(std::process::Stdio::piped());
119 tokio_cmd.kill_on_drop(true);
120
121 let mut child = tokio_cmd
122 .spawn()
123 .map_err(|error| AgentError::Io(format!("Failed to spawn process: {error}")))?;
124
125 let _ = events.send(TurnEvent::PidUpdate(child.id()));
129
130 let raw_stdout = Arc::new(Mutex::new(String::new()));
131 let raw_stderr = Arc::new(Mutex::new(String::new()));
132
133 let stdout_task = {
134 let stdout = child.stdout.take().ok_or_else(|| {
135 AgentError::Io("stdout pipe unavailable after spawn".to_string())
136 })?;
137 let raw_stdout = Arc::clone(&raw_stdout);
138 let events = events.clone();
139
140 tokio::spawn(stream_stdout(stdout, kind, events, raw_stdout))
141 };
142
143 let stderr_task = {
144 let stderr = child.stderr.take().ok_or_else(|| {
145 AgentError::Io("stderr pipe unavailable after spawn".to_string())
146 })?;
147 let raw_stderr = Arc::clone(&raw_stderr);
148
149 tokio::spawn(async move {
150 let mut reader = tokio::io::BufReader::new(stderr).lines();
151 while let Ok(Some(line)) = reader.next_line().await {
152 if let Ok(mut buf) = raw_stderr.lock() {
153 buf.push_str(&line);
154 buf.push('\n');
155 }
156 }
157 })
158 };
159 let stdin_write_task = stdin::spawn_optional_stdin_write(
160 child.stdin.take(),
161 stdin_payload,
162 "stdin pipe unavailable after spawn",
163 AgentError::Io,
164 );
165
166 let _ = stdout_task.await;
168 let _ = stderr_task.await;
170
171 let exit_status = child.wait().await.ok();
172 stdin::await_optional_stdin_write(
173 stdin_write_task,
174 "stdin write task failed",
175 AgentError::Io,
176 )
177 .await?;
178
179 let _ = events.send(TurnEvent::PidUpdate(None));
182
183 let killed_by_signal = exit_status
184 .as_ref()
185 .is_some_and(|status| status.signal().is_some());
186
187 if killed_by_signal {
188 return Err(AgentError::Backend(
189 "[Stopped] Agent interrupted by user.".to_string(),
190 ));
191 }
192
193 let stdout_text = raw_stdout.lock().map(|buf| buf.clone()).unwrap_or_default();
194 let stderr_text = raw_stderr.lock().map(|buf| buf.clone()).unwrap_or_default();
195 if exit_status.as_ref().is_some_and(|status| !status.success()) {
196 return Err(format_cli_turn_exit_error(
197 kind,
198 exit_status.and_then(|status| status.code()),
199 &stdout_text,
200 &stderr_text,
201 ));
202 }
203
204 let parsed = agent::parse_response(kind, &stdout_text, &stderr_text);
205 let assistant_message =
206 parse_or_repair_cli_response(kind, &parsed.content, &req, &backend, &events)
207 .await?;
208
209 Ok(TurnResult {
210 assistant_message,
211 context_reset: false,
212 input_tokens: parsed.stats.input_tokens,
213 output_tokens: parsed.stats.output_tokens,
214 provider_conversation_id: None,
215 })
216 })
217 }
218
219 fn shutdown_session(&self, _session_id: String) -> AgentFuture<Result<(), AgentError>> {
221 Box::pin(async { Ok(()) })
222 }
223}
224
225async fn parse_or_repair_cli_response(
232 kind: AgentKind,
233 content: &str,
234 req: &TurnRequest,
235 backend: &Arc<dyn AgentBackend>,
236 events: &mpsc::UnboundedSender<TurnEvent>,
237) -> Result<agent::AgentResponse, AgentError> {
238 let protocol_profile = req.request_kind.protocol_profile();
239
240 let parse_error = match agent::parse_turn_response(kind, content, protocol_profile) {
241 Ok(response) => return Ok(response),
242 Err(error) => error,
243 };
244
245 let _ = events.send(TurnEvent::ThoughtDelta(format!(
246 "Protocol parse error; retrying schema repair for {kind}."
247 )));
248
249 let repair_prompt = agent::protocol::build_protocol_repair_prompt(&parse_error, content);
250
251 let repair_content = execute_cli_repair_turn(
252 backend.as_ref(),
253 kind,
254 &req.folder,
255 &req.model,
256 &req.request_kind,
257 req.reasoning_level,
258 &repair_prompt,
259 )
260 .await
261 .map_err(|error| {
262 AgentError::Backend(format!(
263 "{parse_error}\nprotocol repair transport failed: {error}"
264 ))
265 })?;
266
267 match agent::parse_turn_response(kind, &repair_content, protocol_profile) {
268 Ok(response) => Ok(response),
269 Err(repair_error) => {
270 if let Some(response) =
271 antigravity_plain_text_fallback(kind, content, &repair_content, protocol_profile)
272 {
273 return Ok(response);
274 }
275
276 Err(AgentError::Backend(format!(
277 "{parse_error}\nprotocol repair retry also failed: \
278 {repair_error}\nrepair_response:\n{repair_content}"
279 )))
280 }
281 }
282}
283
284fn antigravity_plain_text_fallback(
292 kind: AgentKind,
293 original_content: &str,
294 repair_content: &str,
295 protocol_profile: agent::ProtocolRequestProfile,
296) -> Option<agent::AgentResponse> {
297 if kind != AgentKind::Antigravity {
298 return None;
299 }
300
301 let fallback_content =
302 non_empty_content(original_content).or_else(|| non_empty_content(repair_content))?;
303 let mut response = agent::AgentResponse::plain(fallback_content.to_string());
304 if matches!(protocol_profile, agent::ProtocolRequestProfile::SessionTurn) {
305 response.summary = Some(agent::protocol::AgentResponseSummary {
306 session: String::new(),
307 turn: String::new(),
308 });
309 }
310
311 Some(response)
312}
313
314fn non_empty_content(content: &str) -> Option<&str> {
316 let trimmed_content = content.trim();
317 if trimmed_content.is_empty() {
318 return None;
319 }
320
321 Some(trimmed_content)
322}
323
324async fn stream_stdout(
332 stdout: tokio::process::ChildStdout,
333 kind: AgentKind,
334 events: mpsc::UnboundedSender<TurnEvent>,
335 raw_buffer: Arc<Mutex<String>>,
336) {
337 let mut reader = tokio::io::BufReader::new(stdout).lines();
338
339 while let Ok(Some(line)) = reader.next_line().await {
340 if let Ok(mut buf) = raw_buffer.lock() {
341 buf.push_str(&line);
342 buf.push('\n');
343 }
344
345 let Some((text, is_response_content)) = agent::parse_stream_output_line(kind, &line) else {
346 continue;
347 };
348
349 if is_response_content {
350 continue;
351 }
352
353 let trimmed_text = text.trim();
354 if trimmed_text.is_empty() {
355 continue;
356 }
357
358 let _ = events.send(TurnEvent::ThoughtDelta(trimmed_text.to_string()));
360 }
361}
362
363const REPAIR_TURN_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1);
370
371async fn execute_cli_repair_turn(
381 backend: &dyn AgentBackend,
382 kind: AgentKind,
383 folder: &std::path::Path,
384 model: &str,
385 request_kind: &crate::channel::AgentRequestKind,
386 reasoning_level: crate::model::agent::ReasoningLevel,
387 repair_prompt: &str,
388) -> Result<String, String> {
389 let prompt_payload = TurnPrompt::from_agent_data(repair_prompt.to_string());
390 let build_request = BuildCommandRequest {
391 attachments: &prompt_payload.attachments,
392 folder,
393 prompt: repair_prompt,
394 request_kind,
395 model,
396 reasoning_level,
397 };
398 let command = backend
399 .build_command(build_request)
400 .map_err(|error| format!("repair command build failed: {error}"))?;
401 let repair_stdin_payload = agent::build_command_stdin_payload(kind, build_request)
402 .map_err(|error| format!("repair stdin payload build failed: {error}"))?;
403
404 let mut tokio_command = tokio::process::Command::from(command);
405 tokio_command
406 .stdin(if repair_stdin_payload.is_some() {
407 std::process::Stdio::piped()
408 } else {
409 std::process::Stdio::null()
410 })
411 .stdout(std::process::Stdio::piped())
412 .stderr(std::process::Stdio::piped())
413 .kill_on_drop(true);
414
415 let mut child = tokio_command
416 .spawn()
417 .map_err(|error| format!("repair process spawn failed: {error}"))?;
418
419 let repair_stdin_write_task = stdin::spawn_optional_stdin_write(
420 child.stdin.take(),
421 repair_stdin_payload,
422 "repair stdin pipe unavailable after spawn",
423 std::convert::identity,
424 );
425
426 let output = tokio::time::timeout(REPAIR_TURN_TIMEOUT, child.wait_with_output())
427 .await
428 .map_err(|_| {
429 format!(
430 "repair process timed out after {}s",
431 REPAIR_TURN_TIMEOUT.as_secs()
432 )
433 })?
434 .map_err(|error| format!("repair process execution failed: {error}"))?;
435
436 stdin::await_optional_stdin_write(
437 repair_stdin_write_task,
438 "repair stdin write task failed",
439 std::convert::identity,
440 )
441 .await?;
442
443 if !output.status.success() {
444 return Err(format!(
445 "repair process exited with status {}",
446 output.status
447 ));
448 }
449
450 let stdout_text = String::from_utf8_lossy(&output.stdout).into_owned();
451 let stderr_text = String::from_utf8_lossy(&output.stderr).into_owned();
452 let parsed = agent::parse_response(kind, &stdout_text, &stderr_text);
453
454 Ok(parsed.content)
455}
456
457fn format_cli_turn_exit_error(
459 kind: AgentKind,
460 exit_code: Option<i32>,
461 stdout: &str,
462 stderr: &str,
463) -> AgentError {
464 AgentError::Backend(error::format_agent_cli_exit_error(
465 kind,
466 "Agent command",
467 exit_code,
468 stdout,
469 stderr,
470 ))
471}
472
473#[cfg(test)]
474mod tests {
475 use std::path::PathBuf;
476 use std::sync::Arc;
477 use std::time::Duration;
478
479 use tempfile::tempdir;
480 use tokio::sync::mpsc;
481
482 use super::*;
483 use crate::agent::tests::MockAgentBackend;
484 use crate::channel::{
485 AgentRequestKind, TurnPrompt, TurnPromptAttachment, TurnPromptTextSource,
486 };
487 use crate::model::agent::{AgentKind, AgentModel, ReasoningLevel};
488
489 fn make_turn_request(folder: PathBuf) -> TurnRequest {
490 TurnRequest {
491 folder,
492 live_session_output: None,
493 model: "claude-sonnet-5".to_string(),
494 request_kind: AgentRequestKind::SessionStart,
495 prompt: "Write a test".into(),
496 provider_conversation_id: None,
497 persisted_instruction_conversation_id: None,
498 reasoning_level: ReasoningLevel::default(),
499 }
500 }
501
502 fn stdin_capture_command(capture_path: &std::path::Path) -> std::process::Command {
503 let mut command = std::process::Command::new("sh");
504 command.arg("-c").arg(
505 "cat > \"$CLI_CAPTURE_PATH\"; printf '%s' \
506 '{\"answer\":\"ok\",\"questions\":[],\"summary\":null}'",
507 );
508 command.env("CLI_CAPTURE_PATH", capture_path);
509
510 command
511 }
512
513 fn drain_events(receiver: &mut mpsc::UnboundedReceiver<TurnEvent>) -> Vec<TurnEvent> {
515 let mut events = Vec::new();
516 while let Ok(event) = receiver.try_recv() {
517 events.push(event);
518 }
519
520 events
521 }
522
523 #[test]
524 fn test_build_command_request_uses_agent_facing_prompt_text() {
525 let request = TurnRequest {
527 folder: PathBuf::from("/tmp/session"),
528 live_session_output: None,
529 model: "claude-sonnet-5".to_string(),
530 request_kind: AgentRequestKind::SessionStart,
531 prompt: TurnPrompt::from("Review @src/main.rs"),
532 provider_conversation_id: None,
533 persisted_instruction_conversation_id: None,
534 reasoning_level: ReasoningLevel::default(),
535 };
536 let prompt_text = request.prompt.agent_text();
537
538 let build_request = build_command_request(&request, &prompt_text);
540
541 assert_eq!(build_request.prompt, "Review \"src/main.rs\"");
543 }
544
545 #[tokio::test]
546 async fn test_run_turn_spawn_failure_returns_err_without_delta() {
549 let dir = tempdir().expect("failed to create temp dir");
551 let mut mock_backend = MockAgentBackend::new();
552 mock_backend
553 .expect_build_command()
554 .returning(|_| Ok(std::process::Command::new("/no-such-binary-agentty-test")));
555 let channel = CliAgentChannel {
556 backend: Arc::new(mock_backend),
557 kind: AgentKind::Claude,
558 };
559 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
560 let req = make_turn_request(dir.path().to_path_buf());
561
562 let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
564
565 let error_message = result
567 .expect_err("expected Err for spawn failure")
568 .to_string();
569 assert!(
570 error_message.contains("Failed to spawn process"),
571 "error was: {error_message}"
572 );
573 assert!(
574 events_rx.try_recv().is_err(),
575 "no events should be emitted when the process never spawned"
576 );
577 }
578
579 #[tokio::test]
580 async fn test_run_turn_kill_signal_returns_err_without_stopped_delta() {
583 let dir = tempdir().expect("failed to create temp dir");
585 let mut mock_backend = MockAgentBackend::new();
586 mock_backend.expect_build_command().returning(|_| {
587 let mut cmd = std::process::Command::new("sh");
588 cmd.arg("-c").arg("kill -9 $$");
589
590 Ok(cmd)
591 });
592 let channel = CliAgentChannel {
593 backend: Arc::new(mock_backend),
594 kind: AgentKind::Claude,
595 };
596 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
597 let req = make_turn_request(dir.path().to_path_buf());
598
599 let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
601
602 let error_message = result
604 .expect_err("expected Err for kill-by-signal")
605 .to_string();
606 assert!(
607 error_message.contains("[Stopped]"),
608 "error was: {error_message}"
609 );
610
611 while let Ok(event) = events_rx.try_recv() {
613 assert!(
614 matches!(event, TurnEvent::PidUpdate(_)),
615 "only PidUpdate events expected, got: {event:?}"
616 );
617 }
618 }
619
620 #[tokio::test]
621 async fn test_run_turn_clean_exit_returns_ok_result_without_context_reset() {
624 let dir = tempdir().expect("failed to create temp dir");
626 let mut mock_backend = MockAgentBackend::new();
627 mock_backend.expect_build_command().returning(|_| {
628 let mut command = std::process::Command::new("sh");
629 command
630 .arg("-c")
631 .arg("printf '{\"answer\":\"ok\",\"questions\":[],\"summary\":null}'");
632
633 Ok(command)
634 });
635 let channel = CliAgentChannel {
636 backend: Arc::new(mock_backend),
637 kind: AgentKind::Claude,
638 };
639 let (events_tx, _events_rx) = mpsc::unbounded_channel();
640 let req = make_turn_request(dir.path().to_path_buf());
641
642 let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
644
645 let turn_result = result.expect("expected Ok for clean exit");
647 assert!(!turn_result.context_reset);
648 }
649
650 #[tokio::test]
651 async fn test_run_turn_recovers_wrapped_structured_output_for_claude() {
654 let dir = tempdir().expect("failed to create temp dir");
656 let mut mock_backend = MockAgentBackend::new();
657 mock_backend.expect_build_command().returning(|_| {
658 let mut command = std::process::Command::new("sh");
659 command.arg("-c").arg(concat!(
660 "printf '%s\\n' 'Now I have the full context.';",
661 "printf '%s' '{\"answer\":\"ok\",\"questions\":[],\"summary\":null}'",
662 ));
663
664 Ok(command)
665 });
666 let channel = CliAgentChannel {
667 backend: Arc::new(mock_backend),
668 kind: AgentKind::Claude,
669 };
670 let (events_tx, _events_rx) = mpsc::unbounded_channel();
671 let req = make_turn_request(dir.path().to_path_buf());
672
673 let result = channel
675 .run_turn("sess-1".to_string(), req, events_tx)
676 .await
677 .expect("turn should succeed");
678
679 assert_eq!(result.assistant_message.to_answer_display_text(), "ok");
681 }
682
683 #[tokio::test]
684 async fn test_run_turn_fills_missing_summary_for_session_turn() {
687 let dir = tempdir().expect("failed to create temp dir");
689 let mut mock_backend = MockAgentBackend::new();
690 mock_backend.expect_build_command().returning(|_| {
691 let mut command = std::process::Command::new("sh");
692 command
693 .arg("-c")
694 .arg("printf '{\"answer\":\"ok\",\"questions\":[],\"summary\":null}'");
695
696 Ok(command)
697 });
698 let channel = CliAgentChannel {
699 backend: Arc::new(mock_backend),
700 kind: AgentKind::Claude,
701 };
702 let (events_tx, _events_rx) = mpsc::unbounded_channel();
703 let req = make_turn_request(dir.path().to_path_buf());
704
705 let result = channel
707 .run_turn("sess-1".to_string(), req, events_tx)
708 .await
709 .expect("turn should succeed");
710
711 assert_eq!(
713 result.assistant_message.summary,
714 Some(agent::protocol::AgentResponseSummary {
715 turn: String::new(),
716 session: String::new(),
717 })
718 );
719 }
720
721 #[tokio::test]
722 async fn test_run_turn_writes_large_stdin_concurrently_for_claude() {
725 let dir = tempdir().expect("failed to create temp dir");
727 let mut mock_backend = MockAgentBackend::new();
728 mock_backend.expect_build_command().returning(|_| {
729 let mut command = std::process::Command::new("sh");
730 command.arg("-c").arg(
731 "printf 'warming up\\n' >&2; sleep 0.1; cat >/dev/null; printf '%s' \
732 '{\"answer\":\"ok\",\"questions\":[],\"summary\":null}'",
733 );
734
735 Ok(command)
736 });
737 let channel = CliAgentChannel {
738 backend: Arc::new(mock_backend),
739 kind: AgentKind::Claude,
740 };
741 let (events_tx, _events_rx) = mpsc::unbounded_channel();
742 let mut req = make_turn_request(dir.path().to_path_buf());
743 req.prompt = "x".repeat(512 * 1024).into();
744
745 let result = tokio::time::timeout(
747 Duration::from_secs(5),
748 channel.run_turn("sess-1".to_string(), req, events_tx),
749 )
750 .await
751 .expect("turn should not deadlock")
752 .expect("turn should succeed");
753
754 assert_eq!(result.assistant_message.to_display_text(), "ok");
756 }
757
758 #[tokio::test]
759 async fn test_run_turn_writes_prompt_to_stdin_for_claude() {
762 let dir = tempdir().expect("failed to create temp dir");
764 let capture_path = dir.path().join("stdin.txt");
765 let image_path = dir.path().join("pasted-image.png");
766 std::fs::write(&image_path, b"image-bytes").expect("image should be written");
767 let mut mock_backend = MockAgentBackend::new();
768 mock_backend.expect_build_command().returning({
769 let capture_path = capture_path.clone();
770
771 move |_| Ok(stdin_capture_command(&capture_path))
772 });
773 let channel = CliAgentChannel {
774 backend: Arc::new(mock_backend),
775 kind: AgentKind::Claude,
776 };
777 let (events_tx, _events_rx) = mpsc::unbounded_channel();
778 let mut req = make_turn_request(dir.path().to_path_buf());
779 req.prompt = TurnPrompt {
780 attachments: vec![TurnPromptAttachment {
781 placeholder: "[Image #1]".to_string(),
782 local_image_path: image_path.clone(),
783 }],
784 text: "Review [Image #1]".to_string(),
785 text_source: TurnPromptTextSource::UserPrompt,
786 };
787
788 let result = channel
790 .run_turn("sess-1".to_string(), req, events_tx)
791 .await
792 .expect("turn should succeed");
793 let captured_prompt =
794 std::fs::read_to_string(&capture_path).expect("captured stdin payload should exist");
795
796 assert_eq!(result.assistant_message.to_display_text(), "ok");
798 assert!(captured_prompt.contains("Structured response protocol:"));
799 assert!(captured_prompt.contains(image_path.to_string_lossy().as_ref()));
800 assert!(!captured_prompt.contains("[Image #1]"));
801 }
802
803 #[tokio::test]
804 async fn test_run_turn_preserves_child_error_after_broken_pipe() {
807 let dir = tempdir().expect("failed to create temp dir");
809 let mut mock_backend = MockAgentBackend::new();
810 mock_backend.expect_build_command().returning(|_| {
811 let mut command = std::process::Command::new("sh");
812 command.arg("-c").arg("printf 'auth failed' >&2; exit 9");
813
814 Ok(command)
815 });
816 let channel = CliAgentChannel {
817 backend: Arc::new(mock_backend),
818 kind: AgentKind::Claude,
819 };
820 let (events_tx, _events_rx) = mpsc::unbounded_channel();
821 let mut req = make_turn_request(dir.path().to_path_buf());
822 req.prompt = "x".repeat(512 * 1024).into();
823
824 let error = channel
826 .run_turn("sess-1".to_string(), req, events_tx)
827 .await
828 .expect_err("turn should surface the child exit");
829
830 let error_message = error.to_string();
832 assert!(
833 error_message.contains("auth failed"),
834 "error was: {error_message}"
835 );
836 assert!(
837 !error_message.contains("stdin payload"),
838 "stdin write error should not mask child failure: {error_message}"
839 );
840 }
841
842 #[tokio::test]
843 async fn test_run_turn_returns_error_for_invalid_structured_output_for_claude() {
846 let dir = tempdir().expect("failed to create temp dir");
848 let mut mock_backend = MockAgentBackend::new();
849 mock_backend
850 .expect_build_command()
851 .times(2)
852 .returning(|request| {
853 assert!(matches!(
854 request.request_kind,
855 AgentRequestKind::SessionStart
856 ));
857
858 let mut command = std::process::Command::new("sh");
859 command.arg("-c").arg("printf 'plain non-json response'");
860
861 Ok(command)
862 });
863 let channel = CliAgentChannel {
864 backend: Arc::new(mock_backend),
865 kind: AgentKind::Claude,
866 };
867 let (events_tx, _events_rx) = mpsc::unbounded_channel();
868 let req = make_turn_request(dir.path().to_path_buf());
869
870 let error = channel
872 .run_turn("sess-1".to_string(), req, events_tx)
873 .await
874 .expect_err("invalid structured output should fail");
875
876 let error_message = error.to_string();
878 assert!(error_message.contains("did not match the required JSON schema"));
879 assert!(error_message.contains("response:\nplain non-json response"));
880 }
881
882 #[tokio::test]
883 async fn test_run_turn_recovers_valid_output_via_protocol_repair_for_claude() {
886 let dir = tempdir().expect("failed to create temp dir");
888 let call_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
889 let mut mock_backend = MockAgentBackend::new();
890 mock_backend.expect_build_command().times(2).returning({
891 let counter = Arc::clone(&call_counter);
892
893 move |_| {
894 let call_number = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
895 let mut command = std::process::Command::new("sh");
896
897 if call_number == 0 {
898 command.arg("-c").arg("printf 'plain non-json response'");
899 } else {
900 command.arg("-c").arg(
901 r#"printf '{"answer":"Repaired response","questions":[],"summary":null}'"#,
902 );
903 }
904
905 Ok(command)
906 }
907 });
908 let channel = CliAgentChannel {
909 backend: Arc::new(mock_backend),
910 kind: AgentKind::Claude,
911 };
912 let (events_tx, _events_rx) = mpsc::unbounded_channel();
913 let req = make_turn_request(dir.path().to_path_buf());
914
915 let result = channel
917 .run_turn("sess-1".to_string(), req, events_tx)
918 .await
919 .expect("repair retry should succeed");
920
921 assert_eq!(
923 result.assistant_message.to_display_text(),
924 "Repaired response"
925 );
926 }
927
928 #[tokio::test]
929 async fn test_run_turn_falls_back_to_plain_text_for_antigravity_after_repair_failure() {
932 let dir = tempdir().expect("failed to create temp dir");
934 let call_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
935 let mut mock_backend = MockAgentBackend::new();
936 mock_backend.expect_build_command().times(2).returning({
937 let counter = Arc::clone(&call_counter);
938
939 move |_| {
940 let call_number = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
941 let mut command = std::process::Command::new("sh");
942
943 if call_number == 0 {
944 command
945 .arg("-c")
946 .arg("cat >/dev/null; printf 'Plain Antigravity response'");
947 } else {
948 command
949 .arg("-c")
950 .arg("cat >/dev/null; printf 'Still not JSON'");
951 }
952
953 Ok(command)
954 }
955 });
956 let channel = CliAgentChannel {
957 backend: Arc::new(mock_backend),
958 kind: AgentKind::Antigravity,
959 };
960 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
961 let mut req = make_turn_request(dir.path().to_path_buf());
962 req.model = AgentModel::Gemini31ProPreview
963 .provider_model_str()
964 .to_string();
965
966 let result = channel
968 .run_turn("sess-1".to_string(), req, events_tx)
969 .await
970 .expect("Antigravity prose should fall back to a plain answer");
971
972 assert_eq!(
974 result.assistant_message.to_display_text(),
975 "Plain Antigravity response"
976 );
977 assert_eq!(
978 result.assistant_message.summary,
979 Some(agent::protocol::AgentResponseSummary {
980 session: String::new(),
981 turn: String::new(),
982 })
983 );
984
985 let events = drain_events(&mut events_rx);
986 assert!(events.iter().any(|event| {
987 matches!(
988 event,
989 TurnEvent::ThoughtDelta(text)
990 if text == "Protocol parse error; retrying schema repair for antigravity."
991 )
992 }));
993 assert!(events.iter().all(|event| {
994 !matches!(
995 event,
996 TurnEvent::ThoughtDelta(text)
997 if text.contains("debug_details") || text.contains("response:")
998 )
999 }));
1000 }
1001
1002 #[tokio::test]
1003 async fn test_run_turn_returns_claude_auth_guidance_for_expired_token() {
1006 let dir = tempdir().expect("failed to create temp dir");
1008 let mut mock_backend = MockAgentBackend::new();
1009 mock_backend.expect_build_command().times(1).returning(|_| {
1010 let mut command = std::process::Command::new("sh");
1011 command.arg("-c").arg(
1012 "printf '%s' \
1013 '{\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"\
1014 OAuth token has expired. Please obtain a new token or refresh your existing \
1015 token.\"}}'; exit 1",
1016 );
1017
1018 Ok(command)
1019 });
1020 let channel = CliAgentChannel {
1021 backend: Arc::new(mock_backend),
1022 kind: AgentKind::Claude,
1023 };
1024 let (events_tx, _events_rx) = mpsc::unbounded_channel();
1025 let req = make_turn_request(dir.path().to_path_buf());
1026
1027 let error_message = channel
1029 .run_turn("sess-1".to_string(), req, events_tx)
1030 .await
1031 .expect_err("expired Claude auth should fail")
1032 .to_string();
1033
1034 assert!(
1036 error_message.contains("Agent command failed because Claude authentication expired")
1037 );
1038 assert!(error_message.contains("`claude auth login`"));
1039 assert!(error_message.contains("`claude auth status`"));
1040 }
1041
1042 #[tokio::test]
1043 async fn test_run_turn_returns_exit_error_for_non_zero_status() {
1046 let dir = tempdir().expect("failed to create temp dir");
1048 let mut mock_backend = MockAgentBackend::new();
1049 mock_backend.expect_build_command().times(1).returning(|_| {
1050 let mut command = std::process::Command::new("sh");
1051 command
1052 .arg("-c")
1053 .arg("printf '%s' 'assist failed' >&2; exit 7");
1054
1055 Ok(command)
1056 });
1057 let channel = CliAgentChannel {
1058 backend: Arc::new(mock_backend),
1059 kind: AgentKind::Claude,
1060 };
1061 let (events_tx, _events_rx) = mpsc::unbounded_channel();
1062 let req = make_turn_request(dir.path().to_path_buf());
1063
1064 let error_message = channel
1066 .run_turn("sess-1".to_string(), req, events_tx)
1067 .await
1068 .expect_err("non-zero exit should fail")
1069 .to_string();
1070
1071 assert!(error_message.contains("Agent command failed with exit code 7"));
1073 assert!(error_message.contains("assist failed"));
1074 }
1075
1076 #[tokio::test]
1077 async fn test_run_turn_surfaces_only_loader_updates_for_strict_protocol_provider() {
1080 let dir = tempdir().expect("failed to create temp dir");
1082 let mut mock_backend = MockAgentBackend::new();
1083 mock_backend.expect_build_command().returning(|_| {
1084 let mut command = std::process::Command::new("sh");
1085 command.arg("-c").arg(concat!(
1086 r#"echo '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash"}]}}';"#,
1087 r#"echo '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"streamed fragment"}]}}';"#,
1088 r#"echo '{"result":"{\"answer\":\"final answer\",\"questions\":[],\"summary\":null}","usage":{"input_tokens":5,"output_tokens":3}}'"#,
1089 ));
1090
1091 Ok(command)
1092 });
1093 let channel = CliAgentChannel {
1094 backend: Arc::new(mock_backend),
1095 kind: AgentKind::Claude,
1096 };
1097 let (events_tx, mut events_rx) = mpsc::unbounded_channel();
1098 let req = make_turn_request(dir.path().to_path_buf());
1099
1100 let result = channel
1102 .run_turn("sess-1".to_string(), req, events_tx)
1103 .await
1104 .expect("turn should succeed");
1105
1106 let mut saw_loader_update = false;
1108 while let Ok(event) = events_rx.try_recv() {
1109 if matches!(event, TurnEvent::ThoughtDelta(_)) {
1110 saw_loader_update = true;
1111 }
1112 }
1113 assert!(saw_loader_update, "loader updates should be streamed live");
1114 assert_eq!(result.assistant_message.to_display_text(), "final answer");
1115 }
1116}