Skip to main content

ag_agent/channel/
cli.rs

1//! CLI subprocess [`AgentChannel`] adapter.
2//!
3//! Spawns a provider CLI process per turn, streams stdout line-by-line as
4//! [`TurnEvent`]s, and parses the final process output when the process exits.
5
6use 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
24/// [`AgentChannel`] adapter that spawns one CLI subprocess per agent turn.
25///
26/// Stdout lines are classified by
27/// [`agent::parse_stream_output_line`] and transient loader updates are
28/// forwarded as [`TurnEvent::ThoughtDelta`]. A kill signal transitions the
29/// turn to a failed state with a `[Stopped]` banner. A spawn failure is
30/// surfaced through [`AgentError`].
31pub(crate) struct CliAgentChannel {
32    /// Provider-specific command builder.
33    backend: Arc<dyn AgentBackend>,
34    /// Provider family used for stream and response parsing.
35    kind: AgentKind,
36}
37
38impl CliAgentChannel {
39    /// Creates a CLI channel backed by the given pre-built backend.
40    ///
41    /// Channel factories use this helper so transport selection can be done
42    /// once before constructing the concrete channel. Tests also use it to
43    /// inject a [`MockAgentBackend`] that controls command construction and
44    /// process spawning without relying on a real provider binary.
45    pub(crate) fn with_backend(backend: Arc<dyn agent::AgentBackend>, kind: AgentKind) -> Self {
46        Self { backend, kind }
47    }
48}
49
50/// Builds the provider backend command request for one CLI turn.
51fn 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    /// Returns a [`SessionRef`] immediately; CLI turns are stateless.
69    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    /// Spawns a CLI process for the turn and streams its output as events.
79    ///
80    /// Stdout lines are parsed with the provider-specific stream parser and
81    /// loader-oriented interim text is forwarded as
82    /// [`TurnEvent::ThoughtDelta`]. After the process exits, usage
83    /// statistics are extracted from the raw stdout/stderr and the final
84    /// parsed response is returned in [`TurnResult`].
85    ///
86    /// # Errors
87    /// Returns [`AgentError`] when command construction fails, the process
88    /// cannot be spawned, or the process is killed by a signal.
89    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            // Notify the consumer of the child PID so cancellation signals can
125            // be sent while the process is running.
126            // Fire-and-forget: receiver may be dropped during shutdown.
127            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            // Task join: panic in the spawned task is not recoverable here.
166            let _ = stdout_task.await;
167            // Task join: panic in the spawned task is not recoverable here.
168            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            // Clear the PID slot now that the child has exited.
179            // Fire-and-forget: receiver may be dropped during shutdown.
180            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    /// No-op; CLI sessions are stateless and require no teardown.
219    fn shutdown_session(&self, _session_id: String) -> AgentFuture<Result<(), AgentError>> {
220        Box::pin(async { Ok(()) })
221    }
222}
223
224/// Parses one CLI turn response strictly, falling back to a single
225/// protocol-repair retry when the initial parse fails.
226///
227/// When repair is attempted, a concise [`TurnEvent::ThoughtDelta`] is emitted
228/// so the user can see that schema repair is in progress without flooding the
229/// session output with parser diagnostics unless the turn ultimately fails.
230async 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
283/// Converts exhausted Antigravity protocol failures into plain answer text.
284///
285/// Antigravity print mode does not currently provide native response-schema
286/// enforcement, and it can ignore the protocol repair prompt by returning
287/// ordinary prose again. After strict parsing and the repair retry both fail,
288/// this preserves the original useful response as `answer` so the session can
289/// complete instead of surfacing an internal schema error to the user.
290fn 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
313/// Returns one trimmed non-empty content string suitable for fallback output.
314fn 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
323/// Reads stdout line-by-line, classifying each line and forwarding loader
324/// updates.
325///
326/// Only non-response-content stream lines are forwarded as
327/// [`TurnEvent::ThoughtDelta`]. Final assistant transcript output is parsed
328/// from the accumulated raw stdout after the process exits. The raw bytes are
329/// also accumulated in `raw_buffer` for final response parsing.
330async 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        // Fire-and-forget: receiver may be dropped during shutdown.
358        let _ = events.send(TurnEvent::ThoughtDelta(trimmed_text.to_string()));
359    }
360}
361
362/// Maximum wall-clock time for one protocol-repair CLI subprocess.
363///
364/// Repair turns ask the agent to re-emit a single JSON object, so they
365/// should complete quickly. The timeout prevents a hung subprocess from
366/// blocking the parent turn indefinitely. `kill_on_drop(true)` on the
367/// child ensures the process is terminated when the future is dropped.
368const REPAIR_TURN_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1);
369
370/// Spawns a fresh CLI process for one protocol-repair retry and returns
371/// the parsed provider content string.
372///
373/// This helper strips down the full turn-execution pipeline to the minimum
374/// needed for repair: command build, spawn, stdout/stderr collection, and
375/// provider response parsing. No streaming, PID tracking, or signal
376/// handling is performed because the repair is a transparent one-shot
377/// correction, not a user-visible turn. A [`REPAIR_TURN_TIMEOUT`] guard
378/// ensures a stuck process does not block the parent turn indefinitely.
379async 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
458/// Formats one failed CLI turn into a user-facing error.
459fn 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    /// Drains all currently buffered turn events from a test receiver.
516    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        // Arrange
528        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        // Act
543        let build_request = build_command_request(&request, &prompt_text);
544
545        // Assert
546        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    /// Verifies spawn failure returns `Err` with a descriptive message and
555    /// does not emit any turn events when the process never starts.
556    async fn test_run_turn_spawn_failure_returns_err_without_delta() {
557        // Arrange
558        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        // Act
571        let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
572
573        // Assert
574        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    /// Verifies kill-by-signal returns `Err` with a `[Stopped]` message and
589    /// does not emit any loader updates.
590    async fn test_run_turn_kill_signal_returns_err_without_stopped_delta() {
591        // Arrange
592        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        // Act
608        let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
609
610        // Assert
611        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        // Drain `PidUpdate` events and verify no loader update was emitted.
620        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    /// Verifies that a clean process exit returns `Ok(TurnResult)` with no
630    /// context reset (CLI turns never reset context).
631    async fn test_run_turn_clean_exit_returns_ok_result_without_context_reset() {
632        // Arrange
633        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        // Act
651        let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
652
653        // Assert
654        let turn_result = result.expect("expected Ok for clean exit");
655        assert!(!turn_result.context_reset);
656    }
657
658    #[tokio::test]
659    /// Verifies strict turn parsing recovers one trailing protocol payload
660    /// when Claude prepends extra prose before the final JSON object.
661    async fn test_run_turn_recovers_wrapped_structured_output_for_claude() {
662        // Arrange
663        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        // Act
682        let result = channel
683            .run_turn("sess-1".to_string(), req, events_tx)
684            .await
685            .expect("turn should succeed");
686
687        // Assert
688        assert_eq!(result.assistant_message.to_answer_display_text(), "ok");
689    }
690
691    #[tokio::test]
692    /// Verifies session-turn Claude responses synthesize an empty summary when
693    /// the provider returns `summary: null`.
694    async fn test_run_turn_fills_missing_summary_for_session_turn() {
695        // Arrange
696        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        // Act
714        let result = channel
715            .run_turn("sess-1".to_string(), req, events_tx)
716            .await
717            .expect("turn should succeed");
718
719        // Assert
720        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    /// Verifies Claude CLI turns avoid deadlock when the child emits stderr
731    /// before it starts reading a large stdin prompt.
732    async fn test_run_turn_writes_large_stdin_concurrently_for_claude() {
733        // Arrange
734        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        // Act
754        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
763        assert_eq!(result.assistant_message.to_display_text(), "ok");
764    }
765
766    #[tokio::test]
767    /// Verifies Claude CLI turns stream image-aware prompt text through stdin
768    /// so large multimodal session prompts do not rely on argv transport.
769    async fn test_run_turn_writes_prompt_to_stdin_for_claude() {
770        // Arrange
771        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        // Act
797        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
805        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    /// Verifies a broken stdin pipe does not hide the backend stderr or exit
813    /// status when the CLI exits before consuming the full prompt.
814    async fn test_run_turn_preserves_child_error_after_broken_pipe() {
815        // Arrange
816        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        // Act
833        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        // Assert
839        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    /// Verifies Claude turns surface invalid structured output after both the
852    /// original parse and the protocol-repair retry fail.
853    async fn test_run_turn_returns_error_for_invalid_structured_output_for_claude() {
854        // Arrange
855        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        // Act
879        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        // Assert
885        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    /// Verifies Claude turns recover valid output when the initial parse fails
892    /// but the protocol-repair retry returns valid protocol JSON.
893    async fn test_run_turn_recovers_valid_output_via_protocol_repair_for_claude() {
894        // Arrange
895        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        // Act
924        let result = channel
925            .run_turn("sess-1".to_string(), req, events_tx)
926            .await
927            .expect("repair retry should succeed");
928
929        // Assert
930        assert_eq!(
931            result.assistant_message.to_display_text(),
932            "Repaired response"
933        );
934    }
935
936    #[tokio::test]
937    /// Verifies Antigravity preserves useful prose when both strict parsing
938    /// and the protocol-repair retry produce non-JSON text.
939    async fn test_run_turn_falls_back_to_plain_text_for_antigravity_after_repair_failure() {
940        // Arrange
941        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        // Act
975        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
981        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    /// Verifies non-zero CLI turn exits surface actionable Claude
1012    /// re-authentication guidance instead of protocol schema errors.
1013    async fn test_run_turn_returns_claude_auth_guidance_for_expired_token() {
1014        // Arrange
1015        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        // Act
1036        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
1043        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    /// Verifies non-zero CLI turn exits preserve generic stderr details for
1052    /// non-authentication failures.
1053    async fn test_run_turn_returns_exit_error_for_non_zero_status() {
1054        // Arrange
1055        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        // Act
1073        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
1080        assert!(error_message.contains("Agent command failed with exit code 7"));
1081        assert!(error_message.contains("assist failed"));
1082    }
1083
1084    #[tokio::test]
1085    /// Verifies CLI channels surface only transient loader text while the
1086    /// final assistant response is returned at turn completion.
1087    async fn test_run_turn_surfaces_only_loader_updates_for_strict_protocol_provider() {
1088        // Arrange
1089        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        // Act
1109        let result = channel
1110            .run_turn("sess-1".to_string(), req, events_tx)
1111            .await
1112            .expect("turn should succeed");
1113
1114        // Assert
1115        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}