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