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, 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
23/// [`AgentChannel`] adapter that spawns one CLI subprocess per agent turn.
24///
25/// Stdout lines are classified by
26/// [`agent::parse_stream_output_line`] and transient loader updates are
27/// forwarded as [`TurnEvent::ThoughtDelta`]. A kill signal transitions the
28/// turn to a failed state with a `[Stopped]` banner. A spawn failure is
29/// surfaced through [`AgentError`].
30pub struct CliAgentChannel {
31    /// Provider-specific command builder.
32    backend: Arc<dyn AgentBackend>,
33    /// Provider family used for stream and response parsing.
34    kind: AgentKind,
35}
36
37impl CliAgentChannel {
38    /// Creates a new CLI channel for the given agent provider.
39    pub fn new(kind: AgentKind) -> Self {
40        let backend = Arc::from(agent::create_backend(kind));
41
42        Self { backend, kind }
43    }
44
45    /// Creates a CLI channel backed by the given pre-built backend.
46    ///
47    /// Channel factories use this helper so transport selection can be done
48    /// once before constructing the concrete channel. Tests also use it to
49    /// inject a [`MockAgentBackend`] that controls command construction and
50    /// process spawning without relying on a real provider binary.
51    pub fn with_backend(backend: Arc<dyn agent::AgentBackend>, kind: AgentKind) -> Self {
52        Self { backend, kind }
53    }
54}
55
56/// Builds the provider backend command request for one CLI turn.
57fn 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    /// Returns a [`SessionRef`] immediately; CLI turns are stateless.
75    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    /// Spawns a CLI process for the turn and streams its output as events.
85    ///
86    /// Stdout lines are parsed with the provider-specific stream parser and
87    /// loader-oriented interim text is forwarded as
88    /// [`TurnEvent::ThoughtDelta`]. After the process exits, usage
89    /// statistics are extracted from the raw stdout/stderr and the final
90    /// parsed response is returned in [`TurnResult`].
91    ///
92    /// # Errors
93    /// Returns [`AgentError`] when command construction fails, the process
94    /// cannot be spawned, or the process is killed by a signal.
95    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            // Notify the consumer of the child PID so cancellation signals can
131            // be sent while the process is running.
132            // Fire-and-forget: receiver may be dropped during shutdown.
133            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            // Task join: panic in the spawned task is not recoverable here.
172            let _ = stdout_task.await;
173            // Task join: panic in the spawned task is not recoverable here.
174            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            // Clear the PID slot now that the child has exited.
185            // Fire-and-forget: receiver may be dropped during shutdown.
186            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    /// No-op; CLI sessions are stateless and require no teardown.
225    fn shutdown_session(&self, _session_id: String) -> AgentFuture<Result<(), AgentError>> {
226        Box::pin(async { Ok(()) })
227    }
228}
229
230/// Parses one CLI turn response strictly, falling back to a single
231/// protocol-repair retry when the initial parse fails.
232///
233/// When repair is attempted, a concise [`TurnEvent::ThoughtDelta`] is emitted
234/// so the user can see that schema repair is in progress without flooding the
235/// session output with parser diagnostics unless the turn ultimately fails.
236async 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
289/// Converts exhausted Antigravity protocol failures into plain answer text.
290///
291/// Antigravity print mode does not currently provide native response-schema
292/// enforcement, and it can ignore the protocol repair prompt by returning
293/// ordinary prose again. After strict parsing and the repair retry both fail,
294/// this preserves the original useful response as `answer` so the session can
295/// complete instead of surfacing an internal schema error to the user.
296fn 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
319/// Returns one trimmed non-empty content string suitable for fallback output.
320fn 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
329/// Reads stdout line-by-line, classifying each line and forwarding loader
330/// updates.
331///
332/// Only non-response-content stream lines are forwarded as
333/// [`TurnEvent::ThoughtDelta`]. Final assistant transcript output is parsed
334/// from the accumulated raw stdout after the process exits. The raw bytes are
335/// also accumulated in `raw_buffer` for final response parsing.
336async 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        // Fire-and-forget: receiver may be dropped during shutdown.
364        let _ = events.send(TurnEvent::ThoughtDelta(trimmed_text.to_string()));
365    }
366}
367
368/// Maximum wall-clock time for one protocol-repair CLI subprocess.
369///
370/// Repair turns ask the agent to re-emit a single JSON object, so they
371/// should complete quickly. The timeout prevents a hung subprocess from
372/// blocking the parent turn indefinitely. `kill_on_drop(true)` on the
373/// child ensures the process is terminated when the future is dropped.
374const REPAIR_TURN_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1);
375
376/// Spawns a fresh CLI process for one protocol-repair retry and returns
377/// the parsed provider content string.
378///
379/// This helper strips down the full turn-execution pipeline to the minimum
380/// needed for repair: command build, spawn, stdout/stderr collection, and
381/// provider response parsing. No streaming, PID tracking, or signal
382/// handling is performed because the repair is a transparent one-shot
383/// correction, not a user-visible turn. A [`REPAIR_TURN_TIMEOUT`] guard
384/// ensures a stuck process does not block the parent turn indefinitely.
385async 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
464/// Formats one failed CLI turn into a user-facing error.
465fn 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    /// Drains all currently buffered turn events from a test receiver.
523    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        // Arrange
535        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        // Act
550        let build_request = build_command_request(&request, &prompt_text);
551
552        // Assert
553        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    /// Verifies spawn failure returns `Err` with a descriptive message and
562    /// does not emit any turn events when the process never starts.
563    async fn test_run_turn_spawn_failure_returns_err_without_delta() {
564        // Arrange
565        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        // Act
578        let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
579
580        // Assert
581        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    /// Verifies kill-by-signal returns `Err` with a `[Stopped]` message and
596    /// does not emit any loader updates.
597    async fn test_run_turn_kill_signal_returns_err_without_stopped_delta() {
598        // Arrange
599        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        // Act
615        let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
616
617        // Assert
618        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        // Drain `PidUpdate` events and verify no loader update was emitted.
627        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    /// Verifies that a clean process exit returns `Ok(TurnResult)` with no
637    /// context reset (CLI turns never reset context).
638    async fn test_run_turn_clean_exit_returns_ok_result_without_context_reset() {
639        // Arrange
640        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        // Act
658        let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
659
660        // Assert
661        let turn_result = result.expect("expected Ok for clean exit");
662        assert!(!turn_result.context_reset);
663    }
664
665    #[tokio::test]
666    /// Verifies strict turn parsing recovers one trailing protocol payload
667    /// when Claude prepends extra prose before the final JSON object.
668    async fn test_run_turn_recovers_wrapped_structured_output_for_claude() {
669        // Arrange
670        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        // Act
689        let result = channel
690            .run_turn("sess-1".to_string(), req, events_tx)
691            .await
692            .expect("turn should succeed");
693
694        // Assert
695        assert_eq!(result.assistant_message.to_answer_display_text(), "ok");
696    }
697
698    #[tokio::test]
699    /// Verifies session-turn Claude responses synthesize an empty summary when
700    /// the provider returns `summary: null`.
701    async fn test_run_turn_fills_missing_summary_for_session_turn() {
702        // Arrange
703        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        // Act
721        let result = channel
722            .run_turn("sess-1".to_string(), req, events_tx)
723            .await
724            .expect("turn should succeed");
725
726        // Assert
727        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    /// Verifies Claude CLI turns avoid deadlock when the child emits stderr
738    /// before it starts reading a large stdin prompt.
739    async fn test_run_turn_writes_large_stdin_concurrently_for_claude() {
740        // Arrange
741        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        // Act
761        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
770        assert_eq!(result.assistant_message.to_display_text(), "ok");
771    }
772
773    #[tokio::test]
774    /// Verifies Claude CLI turns stream image-aware prompt text through stdin
775    /// so large multimodal session prompts do not rely on argv transport.
776    async fn test_run_turn_writes_prompt_to_stdin_for_claude() {
777        // Arrange
778        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        // Act
804        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
812        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    /// Verifies a broken stdin pipe does not hide the backend stderr or exit
820    /// status when the CLI exits before consuming the full prompt.
821    async fn test_run_turn_preserves_child_error_after_broken_pipe() {
822        // Arrange
823        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        // Act
840        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        // Assert
846        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    /// Verifies Claude turns surface invalid structured output after both the
859    /// original parse and the protocol-repair retry fail.
860    async fn test_run_turn_returns_error_for_invalid_structured_output_for_claude() {
861        // Arrange
862        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        // Act
886        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        // Assert
892        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    /// Verifies Claude turns recover valid output when the initial parse fails
899    /// but the protocol-repair retry returns valid protocol JSON.
900    async fn test_run_turn_recovers_valid_output_via_protocol_repair_for_claude() {
901        // Arrange
902        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        // Act
931        let result = channel
932            .run_turn("sess-1".to_string(), req, events_tx)
933            .await
934            .expect("repair retry should succeed");
935
936        // Assert
937        assert_eq!(
938            result.assistant_message.to_display_text(),
939            "Repaired response"
940        );
941    }
942
943    #[tokio::test]
944    /// Verifies Antigravity preserves useful prose when both strict parsing
945    /// and the protocol-repair retry produce non-JSON text.
946    async fn test_run_turn_falls_back_to_plain_text_for_antigravity_after_repair_failure() {
947        // Arrange
948        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        // Act
982        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
988        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    /// Verifies non-zero CLI turn exits surface actionable Claude
1019    /// re-authentication guidance instead of protocol schema errors.
1020    async fn test_run_turn_returns_claude_auth_guidance_for_expired_token() {
1021        // Arrange
1022        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        // Act
1043        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
1050        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    /// Verifies non-zero CLI turn exits preserve generic stderr details for
1059    /// non-authentication failures.
1060    async fn test_run_turn_returns_exit_error_for_non_zero_status() {
1061        // Arrange
1062        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        // Act
1080        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
1087        assert!(error_message.contains("Agent command failed with exit code 7"));
1088        assert!(error_message.contains("assist failed"));
1089    }
1090
1091    #[tokio::test]
1092    /// Verifies CLI channels surface only transient loader text while the
1093    /// final assistant response is returned at turn completion.
1094    async fn test_run_turn_surfaces_only_loader_updates_for_strict_protocol_provider() {
1095        // Arrange
1096        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        // Act
1116        let result = channel
1117            .run_turn("sess-1".to_string(), req, events_tx)
1118            .await
1119            .expect("turn should succeed");
1120
1121        // Assert
1122        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}