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