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 tokio::io::AsyncBufReadExt as _;
10use tokio::sync::mpsc;
11
12use crate::agent::cli::{error, stdin};
13use crate::agent::{self as agent, AgentBackend, BuildCommandRequest};
14use crate::channel::{
15    AgentChannel, AgentError, AgentFuture, SessionRef, StartSessionRequest, TurnEvent, TurnPrompt,
16    TurnRequest, TurnResult,
17};
18use crate::model::agent::AgentKind;
19
20/// [`AgentChannel`] adapter that spawns one CLI subprocess per agent turn.
21///
22/// Stdout lines are classified by
23/// [`agent::parse_stream_output_line`] and transient loader updates are
24/// forwarded as [`TurnEvent::ThoughtDelta`]. A kill signal transitions the
25/// turn to a failed state with a `[Stopped]` banner. A spawn failure is
26/// surfaced through [`AgentError`].
27pub struct CliAgentChannel {
28    /// Provider-specific command builder.
29    backend: Arc<dyn AgentBackend>,
30    /// Provider family used for stream and response parsing.
31    kind: AgentKind,
32}
33
34impl CliAgentChannel {
35    /// Creates a new CLI channel for the given agent provider.
36    pub fn new(kind: AgentKind) -> Self {
37        let backend = Arc::from(agent::create_backend(kind));
38
39        Self { backend, kind }
40    }
41
42    /// Creates a CLI channel backed by the given pre-built backend.
43    ///
44    /// Channel factories use this helper so transport selection can be done
45    /// once before constructing the concrete channel. Tests also use it to
46    /// inject a [`MockAgentBackend`] that controls command construction and
47    /// process spawning without relying on a real provider binary.
48    pub fn with_backend(backend: Arc<dyn agent::AgentBackend>, kind: AgentKind) -> Self {
49        Self { backend, kind }
50    }
51}
52
53/// Builds the provider backend command request for one CLI turn.
54fn build_command_request<'a>(
55    request: &'a TurnRequest,
56    prompt_text: &'a str,
57) -> BuildCommandRequest<'a> {
58    BuildCommandRequest {
59        attachments: &request.prompt.attachments,
60        folder: &request.folder,
61        prompt: prompt_text,
62        request_kind: &request.request_kind,
63        model: &request.model,
64        reasoning_level: request.reasoning_level,
65    }
66}
67
68impl AgentChannel for CliAgentChannel {
69    /// Returns a [`SessionRef`] immediately; CLI turns are stateless.
70    fn start_session(
71        &self,
72        req: StartSessionRequest,
73    ) -> AgentFuture<Result<SessionRef, AgentError>> {
74        let session_id = req.session_id;
75
76        Box::pin(async move { Ok(SessionRef { session_id }) })
77    }
78
79    /// Spawns a CLI process for the turn and streams its output as events.
80    ///
81    /// Stdout lines are parsed with the provider-specific stream parser and
82    /// loader-oriented interim text is forwarded as
83    /// [`TurnEvent::ThoughtDelta`]. After the process exits, usage
84    /// statistics are extracted from the raw stdout/stderr and the final
85    /// parsed response is returned in [`TurnResult`].
86    ///
87    /// # Errors
88    /// Returns [`AgentError`] when command construction fails, the process
89    /// cannot be spawned, or the process is killed by a signal.
90    fn run_turn(
91        &self,
92        _session_id: String,
93        req: TurnRequest,
94        events: mpsc::UnboundedSender<TurnEvent>,
95    ) -> AgentFuture<Result<TurnResult, AgentError>> {
96        let kind = self.kind;
97        let backend = Arc::clone(&self.backend);
98
99        Box::pin(async move {
100            let prompt_text = req.prompt.agent_text();
101            let build_request = build_command_request(&req, &prompt_text);
102            let build_result = backend.build_command(build_request);
103            let stdin_payload_result = agent::build_command_stdin_payload(kind, build_request);
104            let command = build_result.map_err(|error| {
105                AgentError::Backend(format!("Failed to build command: {error}"))
106            })?;
107            let stdin_payload = stdin_payload_result.map_err(|error| {
108                AgentError::Backend(format!("Failed to build command stdin payload: {error}"))
109            })?;
110
111            let mut tokio_cmd = tokio::process::Command::from(command);
112            tokio_cmd.stdin(if stdin_payload.is_some() {
113                std::process::Stdio::piped()
114            } else {
115                std::process::Stdio::null()
116            });
117            tokio_cmd.stdout(std::process::Stdio::piped());
118            tokio_cmd.stderr(std::process::Stdio::piped());
119            tokio_cmd.kill_on_drop(true);
120
121            let mut child = tokio_cmd
122                .spawn()
123                .map_err(|error| AgentError::Io(format!("Failed to spawn process: {error}")))?;
124
125            // Notify the consumer of the child PID so cancellation signals can
126            // be sent while the process is running.
127            // Fire-and-forget: receiver may be dropped during shutdown.
128            let _ = events.send(TurnEvent::PidUpdate(child.id()));
129
130            let raw_stdout = Arc::new(Mutex::new(String::new()));
131            let raw_stderr = Arc::new(Mutex::new(String::new()));
132
133            let stdout_task = {
134                let stdout = child.stdout.take().ok_or_else(|| {
135                    AgentError::Io("stdout pipe unavailable after spawn".to_string())
136                })?;
137                let raw_stdout = Arc::clone(&raw_stdout);
138                let events = events.clone();
139
140                tokio::spawn(stream_stdout(stdout, kind, events, raw_stdout))
141            };
142
143            let stderr_task = {
144                let stderr = child.stderr.take().ok_or_else(|| {
145                    AgentError::Io("stderr pipe unavailable after spawn".to_string())
146                })?;
147                let raw_stderr = Arc::clone(&raw_stderr);
148
149                tokio::spawn(async move {
150                    let mut reader = tokio::io::BufReader::new(stderr).lines();
151                    while let Ok(Some(line)) = reader.next_line().await {
152                        if let Ok(mut buf) = raw_stderr.lock() {
153                            buf.push_str(&line);
154                            buf.push('\n');
155                        }
156                    }
157                })
158            };
159            let stdin_write_task = stdin::spawn_optional_stdin_write(
160                child.stdin.take(),
161                stdin_payload,
162                "stdin pipe unavailable after spawn",
163                AgentError::Io,
164            );
165
166            // Task join: panic in the spawned task is not recoverable here.
167            let _ = stdout_task.await;
168            // Task join: panic in the spawned task is not recoverable here.
169            let _ = stderr_task.await;
170
171            let exit_status = child.wait().await.ok();
172            stdin::await_optional_stdin_write(
173                stdin_write_task,
174                "stdin write task failed",
175                AgentError::Io,
176            )
177            .await?;
178
179            // Clear the PID slot now that the child has exited.
180            // Fire-and-forget: receiver may be dropped during shutdown.
181            let _ = events.send(TurnEvent::PidUpdate(None));
182
183            let killed_by_signal = exit_status
184                .as_ref()
185                .is_some_and(|status| status.signal().is_some());
186
187            if killed_by_signal {
188                return Err(AgentError::Backend(
189                    "[Stopped] Agent interrupted by user.".to_string(),
190                ));
191            }
192
193            let stdout_text = raw_stdout.lock().map(|buf| buf.clone()).unwrap_or_default();
194            let stderr_text = raw_stderr.lock().map(|buf| buf.clone()).unwrap_or_default();
195            if exit_status.as_ref().is_some_and(|status| !status.success()) {
196                return Err(format_cli_turn_exit_error(
197                    kind,
198                    exit_status.and_then(|status| status.code()),
199                    &stdout_text,
200                    &stderr_text,
201                ));
202            }
203
204            let parsed = agent::parse_response(kind, &stdout_text, &stderr_text);
205            let assistant_message =
206                parse_or_repair_cli_response(kind, &parsed.content, &req, &backend, &events)
207                    .await?;
208
209            Ok(TurnResult {
210                assistant_message,
211                context_reset: false,
212                input_tokens: parsed.stats.input_tokens,
213                output_tokens: parsed.stats.output_tokens,
214                provider_conversation_id: None,
215            })
216        })
217    }
218
219    /// No-op; CLI sessions are stateless and require no teardown.
220    fn shutdown_session(&self, _session_id: String) -> AgentFuture<Result<(), AgentError>> {
221        Box::pin(async { Ok(()) })
222    }
223}
224
225/// Parses one CLI turn response strictly, falling back to a single
226/// protocol-repair retry when the initial parse fails.
227///
228/// When repair is attempted, a concise [`TurnEvent::ThoughtDelta`] is emitted
229/// so the user can see that schema repair is in progress without flooding the
230/// session output with parser diagnostics unless the turn ultimately fails.
231async fn parse_or_repair_cli_response(
232    kind: AgentKind,
233    content: &str,
234    req: &TurnRequest,
235    backend: &Arc<dyn AgentBackend>,
236    events: &mpsc::UnboundedSender<TurnEvent>,
237) -> Result<agent::AgentResponse, AgentError> {
238    let protocol_profile = req.request_kind.protocol_profile();
239
240    let parse_error = match agent::parse_turn_response(kind, content, protocol_profile) {
241        Ok(response) => return Ok(response),
242        Err(error) => error,
243    };
244
245    let _ = events.send(TurnEvent::ThoughtDelta(format!(
246        "Protocol parse error; retrying schema repair for {kind}."
247    )));
248
249    let repair_prompt = agent::protocol::build_protocol_repair_prompt(&parse_error, content);
250
251    let repair_content = execute_cli_repair_turn(
252        backend.as_ref(),
253        kind,
254        &req.folder,
255        &req.model,
256        &req.request_kind,
257        req.reasoning_level,
258        &repair_prompt,
259    )
260    .await
261    .map_err(|error| {
262        AgentError::Backend(format!(
263            "{parse_error}\nprotocol repair transport failed: {error}"
264        ))
265    })?;
266
267    match agent::parse_turn_response(kind, &repair_content, protocol_profile) {
268        Ok(response) => Ok(response),
269        Err(repair_error) => {
270            if let Some(response) =
271                antigravity_plain_text_fallback(kind, content, &repair_content, protocol_profile)
272            {
273                return Ok(response);
274            }
275
276            Err(AgentError::Backend(format!(
277                "{parse_error}\nprotocol repair retry also failed: \
278                 {repair_error}\nrepair_response:\n{repair_content}"
279            )))
280        }
281    }
282}
283
284/// Converts exhausted Antigravity protocol failures into plain answer text.
285///
286/// Antigravity print mode does not currently provide native response-schema
287/// enforcement, and it can ignore the protocol repair prompt by returning
288/// ordinary prose again. After strict parsing and the repair retry both fail,
289/// this preserves the original useful response as `answer` so the session can
290/// complete instead of surfacing an internal schema error to the user.
291fn antigravity_plain_text_fallback(
292    kind: AgentKind,
293    original_content: &str,
294    repair_content: &str,
295    protocol_profile: agent::ProtocolRequestProfile,
296) -> Option<agent::AgentResponse> {
297    if kind != AgentKind::Antigravity {
298        return None;
299    }
300
301    let fallback_content =
302        non_empty_content(original_content).or_else(|| non_empty_content(repair_content))?;
303    let mut response = agent::AgentResponse::plain(fallback_content.to_string());
304    if matches!(protocol_profile, agent::ProtocolRequestProfile::SessionTurn) {
305        response.summary = Some(agent::protocol::AgentResponseSummary {
306            session: String::new(),
307            turn: String::new(),
308        });
309    }
310
311    Some(response)
312}
313
314/// Returns one trimmed non-empty content string suitable for fallback output.
315fn non_empty_content(content: &str) -> Option<&str> {
316    let trimmed_content = content.trim();
317    if trimmed_content.is_empty() {
318        return None;
319    }
320
321    Some(trimmed_content)
322}
323
324/// Reads stdout line-by-line, classifying each line and forwarding loader
325/// updates.
326///
327/// Only non-response-content stream lines are forwarded as
328/// [`TurnEvent::ThoughtDelta`]. Final assistant transcript output is parsed
329/// from the accumulated raw stdout after the process exits. The raw bytes are
330/// also accumulated in `raw_buffer` for final response parsing.
331async fn stream_stdout(
332    stdout: tokio::process::ChildStdout,
333    kind: AgentKind,
334    events: mpsc::UnboundedSender<TurnEvent>,
335    raw_buffer: Arc<Mutex<String>>,
336) {
337    let mut reader = tokio::io::BufReader::new(stdout).lines();
338
339    while let Ok(Some(line)) = reader.next_line().await {
340        if let Ok(mut buf) = raw_buffer.lock() {
341            buf.push_str(&line);
342            buf.push('\n');
343        }
344
345        let Some((text, is_response_content)) = agent::parse_stream_output_line(kind, &line) else {
346            continue;
347        };
348
349        if is_response_content {
350            continue;
351        }
352
353        let trimmed_text = text.trim();
354        if trimmed_text.is_empty() {
355            continue;
356        }
357
358        // Fire-and-forget: receiver may be dropped during shutdown.
359        let _ = events.send(TurnEvent::ThoughtDelta(trimmed_text.to_string()));
360    }
361}
362
363/// Maximum wall-clock time for one protocol-repair CLI subprocess.
364///
365/// Repair turns ask the agent to re-emit a single JSON object, so they
366/// should complete quickly. The timeout prevents a hung subprocess from
367/// blocking the parent turn indefinitely. `kill_on_drop(true)` on the
368/// child ensures the process is terminated when the future is dropped.
369const REPAIR_TURN_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1);
370
371/// Spawns a fresh CLI process for one protocol-repair retry and returns
372/// the parsed provider content string.
373///
374/// This helper strips down the full turn-execution pipeline to the minimum
375/// needed for repair: command build, spawn, stdout/stderr collection, and
376/// provider response parsing. No streaming, PID tracking, or signal
377/// handling is performed because the repair is a transparent one-shot
378/// correction, not a user-visible turn. A [`REPAIR_TURN_TIMEOUT`] guard
379/// ensures a stuck process does not block the parent turn indefinitely.
380async fn execute_cli_repair_turn(
381    backend: &dyn AgentBackend,
382    kind: AgentKind,
383    folder: &std::path::Path,
384    model: &str,
385    request_kind: &crate::channel::AgentRequestKind,
386    reasoning_level: crate::model::agent::ReasoningLevel,
387    repair_prompt: &str,
388) -> Result<String, String> {
389    let prompt_payload = TurnPrompt::from_agent_data(repair_prompt.to_string());
390    let build_request = BuildCommandRequest {
391        attachments: &prompt_payload.attachments,
392        folder,
393        prompt: repair_prompt,
394        request_kind,
395        model,
396        reasoning_level,
397    };
398    let command = backend
399        .build_command(build_request)
400        .map_err(|error| format!("repair command build failed: {error}"))?;
401    let repair_stdin_payload = agent::build_command_stdin_payload(kind, build_request)
402        .map_err(|error| format!("repair stdin payload build failed: {error}"))?;
403
404    let mut tokio_command = tokio::process::Command::from(command);
405    tokio_command
406        .stdin(if repair_stdin_payload.is_some() {
407            std::process::Stdio::piped()
408        } else {
409            std::process::Stdio::null()
410        })
411        .stdout(std::process::Stdio::piped())
412        .stderr(std::process::Stdio::piped())
413        .kill_on_drop(true);
414
415    let mut child = tokio_command
416        .spawn()
417        .map_err(|error| format!("repair process spawn failed: {error}"))?;
418
419    let repair_stdin_write_task = stdin::spawn_optional_stdin_write(
420        child.stdin.take(),
421        repair_stdin_payload,
422        "repair stdin pipe unavailable after spawn",
423        std::convert::identity,
424    );
425
426    let output = tokio::time::timeout(REPAIR_TURN_TIMEOUT, child.wait_with_output())
427        .await
428        .map_err(|_| {
429            format!(
430                "repair process timed out after {}s",
431                REPAIR_TURN_TIMEOUT.as_secs()
432            )
433        })?
434        .map_err(|error| format!("repair process execution failed: {error}"))?;
435
436    stdin::await_optional_stdin_write(
437        repair_stdin_write_task,
438        "repair stdin write task failed",
439        std::convert::identity,
440    )
441    .await?;
442
443    if !output.status.success() {
444        return Err(format!(
445            "repair process exited with status {}",
446            output.status
447        ));
448    }
449
450    let stdout_text = String::from_utf8_lossy(&output.stdout).into_owned();
451    let stderr_text = String::from_utf8_lossy(&output.stderr).into_owned();
452    let parsed = agent::parse_response(kind, &stdout_text, &stderr_text);
453
454    Ok(parsed.content)
455}
456
457/// Formats one failed CLI turn into a user-facing error.
458fn format_cli_turn_exit_error(
459    kind: AgentKind,
460    exit_code: Option<i32>,
461    stdout: &str,
462    stderr: &str,
463) -> AgentError {
464    AgentError::Backend(error::format_agent_cli_exit_error(
465        kind,
466        "Agent command",
467        exit_code,
468        stdout,
469        stderr,
470    ))
471}
472
473#[cfg(test)]
474mod tests {
475    use std::path::PathBuf;
476    use std::sync::Arc;
477    use std::time::Duration;
478
479    use tempfile::tempdir;
480    use tokio::sync::mpsc;
481
482    use super::*;
483    use crate::agent::tests::MockAgentBackend;
484    use crate::channel::{
485        AgentRequestKind, TurnPrompt, TurnPromptAttachment, TurnPromptTextSource,
486    };
487    use crate::model::agent::{AgentKind, AgentModel, ReasoningLevel};
488
489    fn make_turn_request(folder: PathBuf) -> TurnRequest {
490        TurnRequest {
491            folder,
492            live_session_output: None,
493            model: "claude-sonnet-5".to_string(),
494            request_kind: AgentRequestKind::SessionStart,
495            prompt: "Write a test".into(),
496            provider_conversation_id: None,
497            persisted_instruction_conversation_id: None,
498            reasoning_level: ReasoningLevel::default(),
499        }
500    }
501
502    fn stdin_capture_command(capture_path: &std::path::Path) -> std::process::Command {
503        let mut command = std::process::Command::new("sh");
504        command.arg("-c").arg(
505            "cat > \"$CLI_CAPTURE_PATH\"; printf '%s' \
506             '{\"answer\":\"ok\",\"questions\":[],\"summary\":null}'",
507        );
508        command.env("CLI_CAPTURE_PATH", capture_path);
509
510        command
511    }
512
513    /// Drains all currently buffered turn events from a test receiver.
514    fn drain_events(receiver: &mut mpsc::UnboundedReceiver<TurnEvent>) -> Vec<TurnEvent> {
515        let mut events = Vec::new();
516        while let Ok(event) = receiver.try_recv() {
517            events.push(event);
518        }
519
520        events
521    }
522
523    #[test]
524    fn test_build_command_request_uses_agent_facing_prompt_text() {
525        // Arrange
526        let request = TurnRequest {
527            folder: PathBuf::from("/tmp/session"),
528            live_session_output: None,
529            model: "claude-sonnet-5".to_string(),
530            request_kind: AgentRequestKind::SessionStart,
531            prompt: TurnPrompt::from("Review @src/main.rs"),
532            provider_conversation_id: None,
533            persisted_instruction_conversation_id: None,
534            reasoning_level: ReasoningLevel::default(),
535        };
536        let prompt_text = request.prompt.agent_text();
537
538        // Act
539        let build_request = build_command_request(&request, &prompt_text);
540
541        // Assert
542        assert_eq!(build_request.prompt, "Review \"src/main.rs\"");
543    }
544
545    #[tokio::test]
546    /// Verifies spawn failure returns `Err` with a descriptive message and
547    /// does not emit any turn events when the process never starts.
548    async fn test_run_turn_spawn_failure_returns_err_without_delta() {
549        // Arrange
550        let dir = tempdir().expect("failed to create temp dir");
551        let mut mock_backend = MockAgentBackend::new();
552        mock_backend
553            .expect_build_command()
554            .returning(|_| Ok(std::process::Command::new("/no-such-binary-agentty-test")));
555        let channel = CliAgentChannel {
556            backend: Arc::new(mock_backend),
557            kind: AgentKind::Claude,
558        };
559        let (events_tx, mut events_rx) = mpsc::unbounded_channel();
560        let req = make_turn_request(dir.path().to_path_buf());
561
562        // Act
563        let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
564
565        // Assert
566        let error_message = result
567            .expect_err("expected Err for spawn failure")
568            .to_string();
569        assert!(
570            error_message.contains("Failed to spawn process"),
571            "error was: {error_message}"
572        );
573        assert!(
574            events_rx.try_recv().is_err(),
575            "no events should be emitted when the process never spawned"
576        );
577    }
578
579    #[tokio::test]
580    /// Verifies kill-by-signal returns `Err` with a `[Stopped]` message and
581    /// does not emit any loader updates.
582    async fn test_run_turn_kill_signal_returns_err_without_stopped_delta() {
583        // Arrange
584        let dir = tempdir().expect("failed to create temp dir");
585        let mut mock_backend = MockAgentBackend::new();
586        mock_backend.expect_build_command().returning(|_| {
587            let mut cmd = std::process::Command::new("sh");
588            cmd.arg("-c").arg("kill -9 $$");
589
590            Ok(cmd)
591        });
592        let channel = CliAgentChannel {
593            backend: Arc::new(mock_backend),
594            kind: AgentKind::Claude,
595        };
596        let (events_tx, mut events_rx) = mpsc::unbounded_channel();
597        let req = make_turn_request(dir.path().to_path_buf());
598
599        // Act
600        let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
601
602        // Assert
603        let error_message = result
604            .expect_err("expected Err for kill-by-signal")
605            .to_string();
606        assert!(
607            error_message.contains("[Stopped]"),
608            "error was: {error_message}"
609        );
610
611        // Drain `PidUpdate` events and verify no loader update was emitted.
612        while let Ok(event) = events_rx.try_recv() {
613            assert!(
614                matches!(event, TurnEvent::PidUpdate(_)),
615                "only PidUpdate events expected, got: {event:?}"
616            );
617        }
618    }
619
620    #[tokio::test]
621    /// Verifies that a clean process exit returns `Ok(TurnResult)` with no
622    /// context reset (CLI turns never reset context).
623    async fn test_run_turn_clean_exit_returns_ok_result_without_context_reset() {
624        // Arrange
625        let dir = tempdir().expect("failed to create temp dir");
626        let mut mock_backend = MockAgentBackend::new();
627        mock_backend.expect_build_command().returning(|_| {
628            let mut command = std::process::Command::new("sh");
629            command
630                .arg("-c")
631                .arg("printf '{\"answer\":\"ok\",\"questions\":[],\"summary\":null}'");
632
633            Ok(command)
634        });
635        let channel = CliAgentChannel {
636            backend: Arc::new(mock_backend),
637            kind: AgentKind::Claude,
638        };
639        let (events_tx, _events_rx) = mpsc::unbounded_channel();
640        let req = make_turn_request(dir.path().to_path_buf());
641
642        // Act
643        let result = channel.run_turn("sess-1".to_string(), req, events_tx).await;
644
645        // Assert
646        let turn_result = result.expect("expected Ok for clean exit");
647        assert!(!turn_result.context_reset);
648    }
649
650    #[tokio::test]
651    /// Verifies strict turn parsing recovers one trailing protocol payload
652    /// when Claude prepends extra prose before the final JSON object.
653    async fn test_run_turn_recovers_wrapped_structured_output_for_claude() {
654        // Arrange
655        let dir = tempdir().expect("failed to create temp dir");
656        let mut mock_backend = MockAgentBackend::new();
657        mock_backend.expect_build_command().returning(|_| {
658            let mut command = std::process::Command::new("sh");
659            command.arg("-c").arg(concat!(
660                "printf '%s\\n' 'Now I have the full context.';",
661                "printf '%s' '{\"answer\":\"ok\",\"questions\":[],\"summary\":null}'",
662            ));
663
664            Ok(command)
665        });
666        let channel = CliAgentChannel {
667            backend: Arc::new(mock_backend),
668            kind: AgentKind::Claude,
669        };
670        let (events_tx, _events_rx) = mpsc::unbounded_channel();
671        let req = make_turn_request(dir.path().to_path_buf());
672
673        // Act
674        let result = channel
675            .run_turn("sess-1".to_string(), req, events_tx)
676            .await
677            .expect("turn should succeed");
678
679        // Assert
680        assert_eq!(result.assistant_message.to_answer_display_text(), "ok");
681    }
682
683    #[tokio::test]
684    /// Verifies session-turn Claude responses synthesize an empty summary when
685    /// the provider returns `summary: null`.
686    async fn test_run_turn_fills_missing_summary_for_session_turn() {
687        // Arrange
688        let dir = tempdir().expect("failed to create temp dir");
689        let mut mock_backend = MockAgentBackend::new();
690        mock_backend.expect_build_command().returning(|_| {
691            let mut command = std::process::Command::new("sh");
692            command
693                .arg("-c")
694                .arg("printf '{\"answer\":\"ok\",\"questions\":[],\"summary\":null}'");
695
696            Ok(command)
697        });
698        let channel = CliAgentChannel {
699            backend: Arc::new(mock_backend),
700            kind: AgentKind::Claude,
701        };
702        let (events_tx, _events_rx) = mpsc::unbounded_channel();
703        let req = make_turn_request(dir.path().to_path_buf());
704
705        // Act
706        let result = channel
707            .run_turn("sess-1".to_string(), req, events_tx)
708            .await
709            .expect("turn should succeed");
710
711        // Assert
712        assert_eq!(
713            result.assistant_message.summary,
714            Some(agent::protocol::AgentResponseSummary {
715                turn: String::new(),
716                session: String::new(),
717            })
718        );
719    }
720
721    #[tokio::test]
722    /// Verifies Claude CLI turns avoid deadlock when the child emits stderr
723    /// before it starts reading a large stdin prompt.
724    async fn test_run_turn_writes_large_stdin_concurrently_for_claude() {
725        // Arrange
726        let dir = tempdir().expect("failed to create temp dir");
727        let mut mock_backend = MockAgentBackend::new();
728        mock_backend.expect_build_command().returning(|_| {
729            let mut command = std::process::Command::new("sh");
730            command.arg("-c").arg(
731                "printf 'warming up\\n' >&2; sleep 0.1; cat >/dev/null; printf '%s' \
732                 '{\"answer\":\"ok\",\"questions\":[],\"summary\":null}'",
733            );
734
735            Ok(command)
736        });
737        let channel = CliAgentChannel {
738            backend: Arc::new(mock_backend),
739            kind: AgentKind::Claude,
740        };
741        let (events_tx, _events_rx) = mpsc::unbounded_channel();
742        let mut req = make_turn_request(dir.path().to_path_buf());
743        req.prompt = "x".repeat(512 * 1024).into();
744
745        // Act
746        let result = tokio::time::timeout(
747            Duration::from_secs(5),
748            channel.run_turn("sess-1".to_string(), req, events_tx),
749        )
750        .await
751        .expect("turn should not deadlock")
752        .expect("turn should succeed");
753
754        // Assert
755        assert_eq!(result.assistant_message.to_display_text(), "ok");
756    }
757
758    #[tokio::test]
759    /// Verifies Claude CLI turns stream image-aware prompt text through stdin
760    /// so large multimodal session prompts do not rely on argv transport.
761    async fn test_run_turn_writes_prompt_to_stdin_for_claude() {
762        // Arrange
763        let dir = tempdir().expect("failed to create temp dir");
764        let capture_path = dir.path().join("stdin.txt");
765        let image_path = dir.path().join("pasted-image.png");
766        std::fs::write(&image_path, b"image-bytes").expect("image should be written");
767        let mut mock_backend = MockAgentBackend::new();
768        mock_backend.expect_build_command().returning({
769            let capture_path = capture_path.clone();
770
771            move |_| Ok(stdin_capture_command(&capture_path))
772        });
773        let channel = CliAgentChannel {
774            backend: Arc::new(mock_backend),
775            kind: AgentKind::Claude,
776        };
777        let (events_tx, _events_rx) = mpsc::unbounded_channel();
778        let mut req = make_turn_request(dir.path().to_path_buf());
779        req.prompt = TurnPrompt {
780            attachments: vec![TurnPromptAttachment {
781                placeholder: "[Image #1]".to_string(),
782                local_image_path: image_path.clone(),
783            }],
784            text: "Review [Image #1]".to_string(),
785            text_source: TurnPromptTextSource::UserPrompt,
786        };
787
788        // Act
789        let result = channel
790            .run_turn("sess-1".to_string(), req, events_tx)
791            .await
792            .expect("turn should succeed");
793        let captured_prompt =
794            std::fs::read_to_string(&capture_path).expect("captured stdin payload should exist");
795
796        // Assert
797        assert_eq!(result.assistant_message.to_display_text(), "ok");
798        assert!(captured_prompt.contains("Structured response protocol:"));
799        assert!(captured_prompt.contains(image_path.to_string_lossy().as_ref()));
800        assert!(!captured_prompt.contains("[Image #1]"));
801    }
802
803    #[tokio::test]
804    /// Verifies a broken stdin pipe does not hide the backend stderr or exit
805    /// status when the CLI exits before consuming the full prompt.
806    async fn test_run_turn_preserves_child_error_after_broken_pipe() {
807        // Arrange
808        let dir = tempdir().expect("failed to create temp dir");
809        let mut mock_backend = MockAgentBackend::new();
810        mock_backend.expect_build_command().returning(|_| {
811            let mut command = std::process::Command::new("sh");
812            command.arg("-c").arg("printf 'auth failed' >&2; exit 9");
813
814            Ok(command)
815        });
816        let channel = CliAgentChannel {
817            backend: Arc::new(mock_backend),
818            kind: AgentKind::Claude,
819        };
820        let (events_tx, _events_rx) = mpsc::unbounded_channel();
821        let mut req = make_turn_request(dir.path().to_path_buf());
822        req.prompt = "x".repeat(512 * 1024).into();
823
824        // Act
825        let error = channel
826            .run_turn("sess-1".to_string(), req, events_tx)
827            .await
828            .expect_err("turn should surface the child exit");
829
830        // Assert
831        let error_message = error.to_string();
832        assert!(
833            error_message.contains("auth failed"),
834            "error was: {error_message}"
835        );
836        assert!(
837            !error_message.contains("stdin payload"),
838            "stdin write error should not mask child failure: {error_message}"
839        );
840    }
841
842    #[tokio::test]
843    /// Verifies Claude turns surface invalid structured output after both the
844    /// original parse and the protocol-repair retry fail.
845    async fn test_run_turn_returns_error_for_invalid_structured_output_for_claude() {
846        // Arrange
847        let dir = tempdir().expect("failed to create temp dir");
848        let mut mock_backend = MockAgentBackend::new();
849        mock_backend
850            .expect_build_command()
851            .times(2)
852            .returning(|request| {
853                assert!(matches!(
854                    request.request_kind,
855                    AgentRequestKind::SessionStart
856                ));
857
858                let mut command = std::process::Command::new("sh");
859                command.arg("-c").arg("printf 'plain non-json response'");
860
861                Ok(command)
862            });
863        let channel = CliAgentChannel {
864            backend: Arc::new(mock_backend),
865            kind: AgentKind::Claude,
866        };
867        let (events_tx, _events_rx) = mpsc::unbounded_channel();
868        let req = make_turn_request(dir.path().to_path_buf());
869
870        // Act
871        let error = channel
872            .run_turn("sess-1".to_string(), req, events_tx)
873            .await
874            .expect_err("invalid structured output should fail");
875
876        // Assert
877        let error_message = error.to_string();
878        assert!(error_message.contains("did not match the required JSON schema"));
879        assert!(error_message.contains("response:\nplain non-json response"));
880    }
881
882    #[tokio::test]
883    /// Verifies Claude turns recover valid output when the initial parse fails
884    /// but the protocol-repair retry returns valid protocol JSON.
885    async fn test_run_turn_recovers_valid_output_via_protocol_repair_for_claude() {
886        // Arrange
887        let dir = tempdir().expect("failed to create temp dir");
888        let call_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
889        let mut mock_backend = MockAgentBackend::new();
890        mock_backend.expect_build_command().times(2).returning({
891            let counter = Arc::clone(&call_counter);
892
893            move |_| {
894                let call_number = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
895                let mut command = std::process::Command::new("sh");
896
897                if call_number == 0 {
898                    command.arg("-c").arg("printf 'plain non-json response'");
899                } else {
900                    command.arg("-c").arg(
901                        r#"printf '{"answer":"Repaired response","questions":[],"summary":null}'"#,
902                    );
903                }
904
905                Ok(command)
906            }
907        });
908        let channel = CliAgentChannel {
909            backend: Arc::new(mock_backend),
910            kind: AgentKind::Claude,
911        };
912        let (events_tx, _events_rx) = mpsc::unbounded_channel();
913        let req = make_turn_request(dir.path().to_path_buf());
914
915        // Act
916        let result = channel
917            .run_turn("sess-1".to_string(), req, events_tx)
918            .await
919            .expect("repair retry should succeed");
920
921        // Assert
922        assert_eq!(
923            result.assistant_message.to_display_text(),
924            "Repaired response"
925        );
926    }
927
928    #[tokio::test]
929    /// Verifies Antigravity preserves useful prose when both strict parsing
930    /// and the protocol-repair retry produce non-JSON text.
931    async fn test_run_turn_falls_back_to_plain_text_for_antigravity_after_repair_failure() {
932        // Arrange
933        let dir = tempdir().expect("failed to create temp dir");
934        let call_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
935        let mut mock_backend = MockAgentBackend::new();
936        mock_backend.expect_build_command().times(2).returning({
937            let counter = Arc::clone(&call_counter);
938
939            move |_| {
940                let call_number = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
941                let mut command = std::process::Command::new("sh");
942
943                if call_number == 0 {
944                    command
945                        .arg("-c")
946                        .arg("cat >/dev/null; printf 'Plain Antigravity response'");
947                } else {
948                    command
949                        .arg("-c")
950                        .arg("cat >/dev/null; printf 'Still not JSON'");
951                }
952
953                Ok(command)
954            }
955        });
956        let channel = CliAgentChannel {
957            backend: Arc::new(mock_backend),
958            kind: AgentKind::Antigravity,
959        };
960        let (events_tx, mut events_rx) = mpsc::unbounded_channel();
961        let mut req = make_turn_request(dir.path().to_path_buf());
962        req.model = AgentModel::Gemini31ProPreview
963            .provider_model_str()
964            .to_string();
965
966        // Act
967        let result = channel
968            .run_turn("sess-1".to_string(), req, events_tx)
969            .await
970            .expect("Antigravity prose should fall back to a plain answer");
971
972        // Assert
973        assert_eq!(
974            result.assistant_message.to_display_text(),
975            "Plain Antigravity response"
976        );
977        assert_eq!(
978            result.assistant_message.summary,
979            Some(agent::protocol::AgentResponseSummary {
980                session: String::new(),
981                turn: String::new(),
982            })
983        );
984
985        let events = drain_events(&mut events_rx);
986        assert!(events.iter().any(|event| {
987            matches!(
988                event,
989                TurnEvent::ThoughtDelta(text)
990                    if text == "Protocol parse error; retrying schema repair for antigravity."
991            )
992        }));
993        assert!(events.iter().all(|event| {
994            !matches!(
995                event,
996                TurnEvent::ThoughtDelta(text)
997                    if text.contains("debug_details") || text.contains("response:")
998            )
999        }));
1000    }
1001
1002    #[tokio::test]
1003    /// Verifies non-zero CLI turn exits surface actionable Claude
1004    /// re-authentication guidance instead of protocol schema errors.
1005    async fn test_run_turn_returns_claude_auth_guidance_for_expired_token() {
1006        // Arrange
1007        let dir = tempdir().expect("failed to create temp dir");
1008        let mut mock_backend = MockAgentBackend::new();
1009        mock_backend.expect_build_command().times(1).returning(|_| {
1010            let mut command = std::process::Command::new("sh");
1011            command.arg("-c").arg(
1012                "printf '%s' \
1013                 '{\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"\
1014                 OAuth token has expired. Please obtain a new token or refresh your existing \
1015                 token.\"}}'; exit 1",
1016            );
1017
1018            Ok(command)
1019        });
1020        let channel = CliAgentChannel {
1021            backend: Arc::new(mock_backend),
1022            kind: AgentKind::Claude,
1023        };
1024        let (events_tx, _events_rx) = mpsc::unbounded_channel();
1025        let req = make_turn_request(dir.path().to_path_buf());
1026
1027        // Act
1028        let error_message = channel
1029            .run_turn("sess-1".to_string(), req, events_tx)
1030            .await
1031            .expect_err("expired Claude auth should fail")
1032            .to_string();
1033
1034        // Assert
1035        assert!(
1036            error_message.contains("Agent command failed because Claude authentication expired")
1037        );
1038        assert!(error_message.contains("`claude auth login`"));
1039        assert!(error_message.contains("`claude auth status`"));
1040    }
1041
1042    #[tokio::test]
1043    /// Verifies non-zero CLI turn exits preserve generic stderr details for
1044    /// non-authentication failures.
1045    async fn test_run_turn_returns_exit_error_for_non_zero_status() {
1046        // Arrange
1047        let dir = tempdir().expect("failed to create temp dir");
1048        let mut mock_backend = MockAgentBackend::new();
1049        mock_backend.expect_build_command().times(1).returning(|_| {
1050            let mut command = std::process::Command::new("sh");
1051            command
1052                .arg("-c")
1053                .arg("printf '%s' 'assist failed' >&2; exit 7");
1054
1055            Ok(command)
1056        });
1057        let channel = CliAgentChannel {
1058            backend: Arc::new(mock_backend),
1059            kind: AgentKind::Claude,
1060        };
1061        let (events_tx, _events_rx) = mpsc::unbounded_channel();
1062        let req = make_turn_request(dir.path().to_path_buf());
1063
1064        // Act
1065        let error_message = channel
1066            .run_turn("sess-1".to_string(), req, events_tx)
1067            .await
1068            .expect_err("non-zero exit should fail")
1069            .to_string();
1070
1071        // Assert
1072        assert!(error_message.contains("Agent command failed with exit code 7"));
1073        assert!(error_message.contains("assist failed"));
1074    }
1075
1076    #[tokio::test]
1077    /// Verifies CLI channels surface only transient loader text while the
1078    /// final assistant response is returned at turn completion.
1079    async fn test_run_turn_surfaces_only_loader_updates_for_strict_protocol_provider() {
1080        // Arrange
1081        let dir = tempdir().expect("failed to create temp dir");
1082        let mut mock_backend = MockAgentBackend::new();
1083        mock_backend.expect_build_command().returning(|_| {
1084            let mut command = std::process::Command::new("sh");
1085            command.arg("-c").arg(concat!(
1086                r#"echo '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash"}]}}';"#,
1087                r#"echo '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"streamed fragment"}]}}';"#,
1088                r#"echo '{"result":"{\"answer\":\"final answer\",\"questions\":[],\"summary\":null}","usage":{"input_tokens":5,"output_tokens":3}}'"#,
1089            ));
1090
1091            Ok(command)
1092        });
1093        let channel = CliAgentChannel {
1094            backend: Arc::new(mock_backend),
1095            kind: AgentKind::Claude,
1096        };
1097        let (events_tx, mut events_rx) = mpsc::unbounded_channel();
1098        let req = make_turn_request(dir.path().to_path_buf());
1099
1100        // Act
1101        let result = channel
1102            .run_turn("sess-1".to_string(), req, events_tx)
1103            .await
1104            .expect("turn should succeed");
1105
1106        // Assert
1107        let mut saw_loader_update = false;
1108        while let Ok(event) = events_rx.try_recv() {
1109            if matches!(event, TurnEvent::ThoughtDelta(_)) {
1110                saw_loader_update = true;
1111            }
1112        }
1113        assert!(saw_loader_update, "loader updates should be streamed live");
1114        assert_eq!(result.assistant_message.to_display_text(), "final answer");
1115    }
1116}