Skip to main content

ag_agent/agent/
submission.rs

1//! One-shot agent prompt execution helpers.
2//!
3//! These helpers run isolated utility prompts outside the long-lived session
4//! turn flow. They require the shared structured response protocol on every
5//! transport so one-shot callers enforce the same schema contract as normal
6//! session turns.
7
8use std::os::unix::process::ExitStatusExt as _;
9use std::path::Path;
10use std::sync::{Arc, Mutex};
11
12use super::backend::{AgentBackend, BuildCommandRequest};
13use super::cli::{error, stdin};
14use super::protocol::{AgentResponse, parse_agent_response_strict};
15use super::{
16    ParsedResponse, create_app_server_client, create_backend, parse_response, transport_mode,
17};
18use crate::app_server::{AppServerClient, AppServerTurnRequest};
19use crate::channel::AgentRequestKind;
20use crate::model::agent::{AgentKind, AgentModel, ReasoningLevel};
21use crate::model::session::SessionStats;
22
23/// Input payload for one isolated prompt that prefers structured protocol
24/// output.
25#[derive(Clone, Debug)]
26pub struct OneShotRequest<'a> {
27    /// Provider backend used for command construction, stdin shaping, and
28    /// response parsing.
29    pub agent_kind: AgentKind,
30    /// Optional PID slot used by cancel/stop flows to terminate the spawned
31    /// subprocess while a one-shot prompt is running.
32    pub child_pid: Option<&'a Mutex<Option<u32>>>,
33    /// Working directory where the prompt command runs.
34    pub folder: &'a Path,
35    /// Provider-specific model used for command construction and parsing.
36    pub model: AgentModel,
37    /// Prompt text submitted to the agent.
38    pub prompt: &'a str,
39    /// Canonical request kind for this isolated prompt.
40    pub request_kind: AgentRequestKind,
41    /// Reasoning effort preference for the one-shot prompt.
42    pub reasoning_level: ReasoningLevel,
43}
44
45/// Parsed result returned by one isolated prompt execution.
46#[derive(Clone, Debug, PartialEq)]
47pub struct OneShotSubmission {
48    /// Structured protocol response parsed from the final successful attempt.
49    pub response: AgentResponse,
50    /// Aggregated token usage for the one-shot prompt execution.
51    pub stats: SessionStats,
52}
53
54/// Executes one isolated prompt and returns the parsed response.
55///
56/// # Errors
57/// Returns an error when command construction fails, process execution fails,
58/// or the final output is empty or otherwise unusable.
59pub async fn submit_one_shot(request: OneShotRequest<'_>) -> Result<AgentResponse, String> {
60    let submission = submit_one_shot_with_stats(request).await?;
61
62    Ok(submission.response)
63}
64
65/// Executes one isolated prompt and returns the parsed response plus
66/// aggregated usage statistics.
67///
68/// # Errors
69/// Returns an error when command construction fails, process execution fails,
70/// or the final output is empty or otherwise unusable.
71pub(crate) async fn submit_one_shot_with_stats(
72    request: OneShotRequest<'_>,
73) -> Result<OneShotSubmission, String> {
74    submit_one_shot_with_stats_and_app_server_client(request, None).await
75}
76
77/// Executes one isolated prompt and returns the parsed response plus
78/// aggregated usage statistics, optionally overriding the backend-owned
79/// app-server client.
80///
81/// # Errors
82/// Returns an error when command construction fails, process execution fails,
83/// or the final output is empty or otherwise unusable.
84pub(crate) async fn submit_one_shot_with_stats_and_app_server_client(
85    request: OneShotRequest<'_>,
86    app_server_client_override: Option<Arc<dyn AppServerClient>>,
87) -> Result<OneShotSubmission, String> {
88    let backend = create_backend(request.agent_kind);
89
90    if transport_mode(request.agent_kind).uses_app_server() {
91        let app_server_client =
92            create_app_server_client(request.agent_kind, app_server_client_override).ok_or_else(
93                || {
94                    format!(
95                        "{} provider did not provide an app-server client",
96                        request.agent_kind
97                    )
98                },
99            )?;
100
101        return submit_one_shot_with_app_server_client(app_server_client.as_ref(), request).await;
102    }
103
104    submit_one_shot_with_backend(backend.as_ref(), request).await
105}
106
107/// Executes one isolated prompt through the shared app-server transport.
108///
109/// The temporary app-server session is shut down after the utility prompt
110/// finishes so one-shot helpers do not keep a provider runtime alive after the
111/// result has been parsed.
112///
113/// # Errors
114/// Returns an error when app-server turn execution fails or the final output
115/// is empty or otherwise unusable.
116pub async fn submit_one_shot_with_app_server_client(
117    app_server_client: &dyn AppServerClient,
118    request: OneShotRequest<'_>,
119) -> Result<OneShotSubmission, String> {
120    clear_child_pid_slot(request.child_pid);
121
122    let session_id = format!("one-shot-{}", uuid::Uuid::new_v4());
123    let (stream_tx, _stream_rx) = tokio::sync::mpsc::unbounded_channel();
124    let turn_request = AppServerTurnRequest {
125        folder: request.folder.to_path_buf(),
126        live_session_output: None,
127        model: request.model.provider_model_str().to_string(),
128        prompt: crate::model::turn_prompt::TurnPrompt::from_agent_data(request.prompt.to_string()),
129        request_kind: request.request_kind.clone(),
130        provider_conversation_id: None,
131        persisted_instruction_conversation_id: None,
132        reasoning_level: request.reasoning_level,
133        session_id: session_id.clone(),
134    };
135
136    let turn_result = app_server_client.run_turn(turn_request, stream_tx).await;
137
138    let child_pid = request.child_pid;
139
140    let turn_result = match turn_result {
141        Ok(result) => result,
142        Err(error) => {
143            app_server_client.shutdown_session(session_id).await;
144            clear_child_pid_slot(child_pid);
145
146            return Err(format!(
147                "Failed to execute one-shot app-server turn: {error}"
148            ));
149        }
150    };
151
152    let parse_result = match parse_one_shot_response(&turn_result.assistant_message) {
153        Ok(response) => Ok((response, 0, 0)),
154        Err(parse_error) => {
155            attempt_one_shot_app_server_repair(
156                app_server_client,
157                &parse_error,
158                &turn_result.assistant_message,
159                request,
160                &session_id,
161                turn_result.provider_conversation_id.as_deref(),
162            )
163            .await
164        }
165    };
166
167    app_server_client.shutdown_session(session_id).await;
168    clear_child_pid_slot(child_pid);
169
170    let (response, repair_input_tokens, repair_output_tokens) = parse_result?;
171
172    Ok(OneShotSubmission {
173        response,
174        stats: SessionStats {
175            added_lines: 0,
176            deleted_lines: 0,
177            input_tokens: turn_result.input_tokens + repair_input_tokens,
178            output_tokens: turn_result.output_tokens + repair_output_tokens,
179        },
180    })
181}
182
183/// Executes one isolated prompt using the provided backend.
184///
185/// This shared helper keeps process execution behind the existing
186/// `AgentBackend` trait boundary so production callers and tests can reuse
187/// the same one-shot parsing path.
188///
189/// # Errors
190/// Returns an error when command construction fails, process execution fails,
191/// or the final output is empty or otherwise unusable.
192pub async fn submit_one_shot_with_backend(
193    backend: &dyn AgentBackend,
194    request: OneShotRequest<'_>,
195) -> Result<OneShotSubmission, String> {
196    let parsed_response =
197        execute_one_shot_command(backend, request.prompt, request.clone()).await?;
198    let (agent_response, repair_stats) = match parse_one_shot_response(&parsed_response.content) {
199        Ok(response) => (response, None),
200        Err(parse_error) => {
201            let repair_prompt = super::protocol::build_protocol_repair_prompt(
202                &parse_error,
203                &parsed_response.content,
204            );
205            let repair_response = execute_one_shot_command(backend, &repair_prompt, request)
206                .await
207                .map_err(|error| format!("{parse_error}\nrepair transport failed: {error}"))?;
208
209            let response = parse_one_shot_response(&repair_response.content).map_err(|error| {
210                format!(
211                    "{parse_error}\nrepair retry also failed: {error}\nrepair_response:\n{}",
212                    repair_response.content
213                )
214            })?;
215
216            (response, Some(repair_response.stats))
217        }
218    };
219
220    let mut stats = parsed_response.stats;
221    if let Some(repair) = repair_stats {
222        stats.input_tokens += repair.input_tokens;
223        stats.output_tokens += repair.output_tokens;
224    }
225
226    Ok(OneShotSubmission {
227        response: agent_response,
228        stats,
229    })
230}
231
232/// Parses one one-shot response strictly against the shared protocol schema.
233///
234/// # Errors
235/// Returns an error when the response is empty or not valid protocol JSON,
236/// including parse diagnostics that help explain the mismatch.
237fn parse_one_shot_response(content: &str) -> Result<AgentResponse, String> {
238    parse_agent_response_strict(content).map_err(|error| {
239        format!(
240            "One-shot agent output did not match the required JSON schema: \
241             {error}\ndebug_details:\n{}\nresponse:\n{content}",
242            super::protocol::format_protocol_parse_debug_details(content)
243        )
244    })
245}
246
247/// Attempts one protocol-repair retry through the app-server transport for
248/// a one-shot prompt whose initial response failed schema validation.
249///
250/// The repair prompt is sent as a follow-up turn on the same session so the
251/// agent retains the original conversation context. The initial turn's
252/// `provider_conversation_id` is threaded through so providers that depend
253/// on conversation state can continue the same thread.
254///
255/// Returns the parsed response together with the repair turn's token usage
256/// so the caller can aggregate stats across both attempts.
257///
258/// # Errors
259/// Returns the combined original and repair error when the retry fails.
260async fn attempt_one_shot_app_server_repair(
261    app_server_client: &dyn AppServerClient,
262    parse_error: &str,
263    malformed_response: &str,
264    request: OneShotRequest<'_>,
265    session_id: &str,
266    provider_conversation_id: Option<&str>,
267) -> Result<(AgentResponse, u64, u64), String> {
268    let repair_prompt =
269        super::protocol::build_protocol_repair_prompt(parse_error, malformed_response);
270
271    let (repair_stream_tx, _repair_stream_rx) = tokio::sync::mpsc::unbounded_channel();
272    let repair_turn_request = AppServerTurnRequest {
273        folder: request.folder.to_path_buf(),
274        live_session_output: None,
275        model: request.model.provider_model_str().to_string(),
276        prompt: crate::model::turn_prompt::TurnPrompt::from_agent_data(repair_prompt),
277        request_kind: request.request_kind,
278        provider_conversation_id: provider_conversation_id.map(String::from),
279        persisted_instruction_conversation_id: None,
280        reasoning_level: request.reasoning_level,
281        session_id: session_id.to_string(),
282    };
283    let repair_result = app_server_client
284        .run_turn(repair_turn_request, repair_stream_tx)
285        .await
286        .map_err(|error| format!("{parse_error}\nrepair transport failed: {error}"))?;
287
288    let response = parse_one_shot_response(&repair_result.assistant_message).map_err(|error| {
289        format!(
290            "{parse_error}\nrepair retry also failed: {error}\nrepair_response:\n{}",
291            repair_result.assistant_message
292        )
293    })?;
294
295    Ok((
296        response,
297        repair_result.input_tokens,
298        repair_result.output_tokens,
299    ))
300}
301
302/// Runs one one-shot backend command and returns the parsed provider content.
303///
304/// The spawned child is configured with `kill_on_drop(true)` so timeout-driven
305/// callers do not leave orphaned agent CLI processes behind when the future is
306/// canceled before completion.
307///
308/// # Errors
309/// Returns an error when the command cannot be built, run, or exits
310/// unsuccessfully.
311async fn execute_one_shot_command(
312    backend: &dyn AgentBackend,
313    prompt: &str,
314    request: OneShotRequest<'_>,
315) -> Result<ParsedResponse, String> {
316    let prompt_payload = crate::model::turn_prompt::TurnPrompt::from_agent_data(prompt.to_string());
317    let build_request = BuildCommandRequest {
318        attachments: &prompt_payload.attachments,
319        folder: request.folder,
320        prompt,
321        request_kind: &request.request_kind,
322        model: request.model.provider_model_str(),
323        reasoning_level: request.reasoning_level,
324    };
325    let command = backend
326        .build_command(build_request)
327        .map_err(|error| format!("Failed to build one-shot agent command: {error}"))?;
328    let stdin_payload = super::build_command_stdin_payload(request.agent_kind, build_request)
329        .map_err(|error| format!("Failed to build one-shot agent stdin payload: {error}"))?;
330    let mut tokio_command = tokio::process::Command::from(command);
331    tokio_command
332        .stdin(if stdin_payload.is_some() {
333            std::process::Stdio::piped()
334        } else {
335            std::process::Stdio::null()
336        })
337        .kill_on_drop(true);
338    let mut pid_guard = ChildPidGuard::new(request.child_pid);
339    let mut child = tokio_command
340        .spawn()
341        .map_err(|error| format!("Failed to execute one-shot agent command: {error}"))?;
342    pid_guard.update_from_child(&child);
343    let stdin_write_task = stdin::spawn_optional_stdin_write(
344        child.stdin.take(),
345        stdin_payload,
346        "one-shot stdin pipe unavailable after spawn",
347        std::convert::identity,
348    );
349    let output = child
350        .wait_with_output()
351        .await
352        .map_err(|error| format!("Failed to execute one-shot agent command: {error}"))?;
353    stdin::await_optional_stdin_write(
354        stdin_write_task,
355        "One-shot stdin write task failed",
356        std::convert::identity,
357    )
358    .await?;
359
360    if output.status.signal().is_some() {
361        return Err("One-shot agent command was interrupted".to_string());
362    }
363
364    let stdout_text = String::from_utf8_lossy(&output.stdout).into_owned();
365    let stderr_text = String::from_utf8_lossy(&output.stderr).into_owned();
366    if !output.status.success() {
367        return Err(format_one_shot_exit_error(
368            request.agent_kind,
369            output.status.code(),
370            &stdout_text,
371            &stderr_text,
372        ));
373    }
374
375    let parsed_response = parse_response(request.agent_kind, &stdout_text, &stderr_text);
376
377    Ok(parsed_response)
378}
379
380/// Formats one non-zero one-shot command exit into a user-facing error.
381fn format_one_shot_exit_error(
382    agent_kind: AgentKind,
383    exit_code: Option<i32>,
384    stdout: &str,
385    stderr: &str,
386) -> String {
387    error::format_agent_cli_exit_error(
388        agent_kind,
389        "One-shot agent command",
390        exit_code,
391        stdout,
392        stderr,
393    )
394}
395
396/// Clears the shared one-shot child PID slot when one exists.
397fn clear_child_pid_slot(child_pid: Option<&Mutex<Option<u32>>>) {
398    let Some(child_pid) = child_pid else {
399        return;
400    };
401
402    if let Ok(mut guard) = child_pid.lock() {
403        *guard = None;
404    }
405}
406
407/// Tracks the active one-shot subprocess identifier for cancel/stop flows.
408struct ChildPidGuard<'a> {
409    child_pid: Option<&'a Mutex<Option<u32>>>,
410}
411
412impl<'a> ChildPidGuard<'a> {
413    /// Creates one PID guard for the optional shared child slot.
414    fn new(child_pid: Option<&'a Mutex<Option<u32>>>) -> Self {
415        Self { child_pid }
416    }
417
418    /// Copies the spawned child PID into the shared slot when available.
419    fn update_from_child(&mut self, child: &tokio::process::Child) {
420        let Some(pid) = child.id() else {
421            return;
422        };
423
424        let Some(child_pid) = self.child_pid else {
425            return;
426        };
427
428        if let Ok(mut guard) = child_pid.lock() {
429            *guard = Some(pid);
430        }
431    }
432}
433
434impl Drop for ChildPidGuard<'_> {
435    fn drop(&mut self) {
436        let Some(child_pid) = self.child_pid else {
437            return;
438        };
439
440        if let Ok(mut guard) = child_pid.lock() {
441            *guard = None;
442        }
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use std::process::Command;
449    use std::time::Duration;
450
451    use tempfile::tempdir;
452
453    use super::*;
454    use crate::agent::tests::MockAgentBackend;
455    use crate::app_server::{AppServerTurnResponse, MockAppServerClient};
456
457    /// Builds one shell command that emits controlled stdout/stderr and exits.
458    fn mock_shell_command(stdout: &str, stderr: &str, exit_code: i32) -> Command {
459        let mut command = Command::new("sh");
460        command.arg("-c").arg(
461            "printf '%s' \"$ONE_SHOT_STDOUT\"; printf '%s' \"$ONE_SHOT_STDERR\" >&2; exit \
462             \"$ONE_SHOT_EXIT\"",
463        );
464        command.env("ONE_SHOT_STDOUT", stdout);
465        command.env("ONE_SHOT_STDERR", stderr);
466        command.env("ONE_SHOT_EXIT", exit_code.to_string());
467        command.stdout(std::process::Stdio::piped());
468        command.stderr(std::process::Stdio::piped());
469
470        command
471    }
472
473    /// Builds one shell command that captures stdin before returning JSON.
474    fn stdin_capture_shell_command(capture_path: &Path) -> Command {
475        let mut command = Command::new("sh");
476        command.arg("-c").arg(
477            "cat > \"$ONE_SHOT_CAPTURE_PATH\"; printf '%s' \
478             '{\"answer\":\"captured\",\"questions\":[],\"summary\":null}'",
479        );
480        command.env("ONE_SHOT_CAPTURE_PATH", capture_path);
481        command.stdout(std::process::Stdio::piped());
482        command.stderr(std::process::Stdio::piped());
483
484        command
485    }
486
487    #[tokio::test]
488    /// Verifies one-shot execution returns the parsed structured answer.
489    async fn test_submit_one_shot_with_backend_returns_protocol_response() {
490        // Arrange
491        let temp_directory = tempdir().expect("failed to create temp dir");
492        let mut backend = MockAgentBackend::new();
493        backend.expect_build_command().returning(|request| {
494            assert!(matches!(
495                request.request_kind,
496                AgentRequestKind::UtilityPrompt
497            ));
498            assert_eq!(request.prompt, "Generate title");
499
500            Ok(mock_shell_command(
501                r#"{"answer":"Generated title","questions":[],"summary":null}"#,
502                "",
503                0,
504            ))
505        });
506
507        // Act
508        let response = submit_one_shot_with_backend(
509            &backend,
510            OneShotRequest {
511                agent_kind: AgentKind::Claude,
512                child_pid: None,
513                folder: temp_directory.path(),
514                model: AgentModel::ClaudeSonnet5,
515                prompt: "Generate title",
516                request_kind: AgentRequestKind::UtilityPrompt,
517                reasoning_level: ReasoningLevel::default(),
518            },
519        )
520        .await
521        .expect("one-shot prompt should succeed");
522
523        // Assert
524        assert_eq!(
525            response.response.answers(),
526            vec!["Generated title".to_string()]
527        );
528    }
529
530    #[tokio::test]
531    /// Verifies one-shot execution rejects plain-text utility output after
532    /// both the original parse and the protocol-repair retry fail.
533    async fn test_submit_one_shot_with_backend_rejects_plain_text_utility_output() {
534        // Arrange
535        let temp_directory = tempdir().expect("failed to create temp dir");
536        let mut backend = MockAgentBackend::new();
537        backend
538            .expect_build_command()
539            .times(2)
540            .returning(|request| {
541                assert!(matches!(
542                    request.request_kind,
543                    AgentRequestKind::UtilityPrompt
544                ));
545
546                Ok(mock_shell_command("plain text", "", 0))
547            });
548
549        // Act
550        let error = submit_one_shot_with_backend(
551            &backend,
552            OneShotRequest {
553                agent_kind: AgentKind::Codex,
554                child_pid: None,
555                folder: temp_directory.path(),
556                model: AgentModel::Gpt55,
557                prompt: "Generate title",
558                request_kind: AgentRequestKind::UtilityPrompt,
559                reasoning_level: ReasoningLevel::default(),
560            },
561        )
562        .await
563        .expect_err("plain-text utility output should fail");
564
565        // Assert
566        assert!(error.contains("did not match the required JSON schema"));
567        assert!(error.contains("debug_details:"));
568        assert!(error.contains("direct_json_error_location: line 1, column 1"));
569        assert!(error.contains("response:\nplain text"));
570    }
571
572    #[tokio::test]
573    /// Verifies one-shot execution rejects wrapped non-schema utility output
574    /// after both the original parse and the protocol-repair retry fail.
575    async fn test_submit_one_shot_with_backend_rejects_wrapped_plain_text_utility_output() {
576        // Arrange
577        let temp_directory = tempdir().expect("failed to create temp dir");
578        let mut backend = MockAgentBackend::new();
579        backend
580            .expect_build_command()
581            .times(2)
582            .returning(|request| {
583                assert!(matches!(
584                    request.request_kind,
585                    AgentRequestKind::UtilityPrompt
586                ));
587
588                Ok(mock_shell_command(
589                    r#"{"result":"plain text","usage":{"input_tokens":2,"output_tokens":1}}"#,
590                    "",
591                    0,
592                ))
593            });
594
595        // Act
596        let error = submit_one_shot_with_backend(
597            &backend,
598            OneShotRequest {
599                agent_kind: AgentKind::Claude,
600                child_pid: None,
601                folder: temp_directory.path(),
602                model: AgentModel::ClaudeSonnet5,
603                prompt: "Generate title",
604                request_kind: AgentRequestKind::UtilityPrompt,
605                reasoning_level: ReasoningLevel::default(),
606            },
607        )
608        .await
609        .expect_err("wrapped plain-text utility output should fail");
610
611        // Assert — the provider parser extracts "plain text" from the
612        // `result` wrapper, so the protocol parser sees raw text, not JSON keys.
613        assert!(error.contains("did not match the required JSON schema"));
614        assert!(error.contains("direct_json_error:"));
615        assert!(error.contains("response:\nplain text"));
616    }
617
618    #[tokio::test]
619    /// Verifies one-shot execution recovers a trailing protocol payload when
620    /// the provider prepends extra prose before the final JSON object.
621    async fn test_submit_one_shot_with_backend_recovers_wrapped_protocol_output() {
622        // Arrange
623        let temp_directory = tempdir().expect("failed to create temp dir");
624        let mut backend = MockAgentBackend::new();
625        backend
626            .expect_build_command()
627            .times(1)
628            .returning(|request| {
629                assert!(matches!(
630                    request.request_kind,
631                    AgentRequestKind::UtilityPrompt
632                ));
633                assert_eq!(request.prompt, "Generate title");
634
635                Ok(mock_shell_command(
636                    concat!(
637                        "Now I have full context.\n",
638                        r#"{"answer":"Generated title","questions":[],"summary":null}"#
639                    ),
640                    "",
641                    0,
642                ))
643            });
644
645        // Act
646        let response = submit_one_shot_with_backend(
647            &backend,
648            OneShotRequest {
649                agent_kind: AgentKind::Claude,
650                child_pid: None,
651                folder: temp_directory.path(),
652                model: AgentModel::ClaudeSonnet5,
653                prompt: "Generate title",
654                request_kind: AgentRequestKind::UtilityPrompt,
655                reasoning_level: ReasoningLevel::default(),
656            },
657        )
658        .await
659        .expect("wrapped protocol output should succeed");
660
661        // Assert
662        assert_eq!(
663            response.response.answers(),
664            vec!["Generated title".to_string()]
665        );
666    }
667
668    #[tokio::test]
669    /// Verifies one-shot execution recovers valid output when the initial
670    /// parse fails but the protocol-repair retry returns valid protocol JSON.
671    async fn test_submit_one_shot_with_backend_recovers_via_protocol_repair() {
672        // Arrange
673        let temp_directory = tempdir().expect("failed to create temp dir");
674        let call_counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
675        let mut backend = MockAgentBackend::new();
676        backend.expect_build_command().times(2).returning({
677            let counter = std::sync::Arc::clone(&call_counter);
678
679            move |_| {
680                let call_number = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
681
682                if call_number == 0 {
683                    Ok(mock_shell_command("plain text", "", 0))
684                } else {
685                    Ok(mock_shell_command(
686                        r#"{"answer":"Repaired title","questions":[],"summary":null}"#,
687                        "",
688                        0,
689                    ))
690                }
691            }
692        });
693
694        // Act
695        let response = submit_one_shot_with_backend(
696            &backend,
697            OneShotRequest {
698                agent_kind: AgentKind::Codex,
699                child_pid: None,
700                folder: temp_directory.path(),
701                model: AgentModel::Gpt55,
702                prompt: "Generate title",
703                request_kind: AgentRequestKind::UtilityPrompt,
704                reasoning_level: ReasoningLevel::default(),
705            },
706        )
707        .await
708        .expect("repair retry should succeed");
709
710        // Assert
711        assert_eq!(
712            response.response.answers(),
713            vec!["Repaired title".to_string()]
714        );
715    }
716
717    #[tokio::test]
718    /// Verifies one-shot execution still rejects blank utility responses
719    /// after both the original parse and the protocol-repair retry fail.
720    async fn test_submit_one_shot_with_backend_rejects_blank_utility_output() {
721        // Arrange
722        let temp_directory = tempdir().expect("failed to create temp dir");
723        let mut backend = MockAgentBackend::new();
724        backend.expect_build_command().returning(|request| {
725            assert!(matches!(
726                request.request_kind,
727                AgentRequestKind::UtilityPrompt
728            ));
729
730            Ok(mock_shell_command("   ", "", 0))
731        });
732
733        // Act
734        let error = submit_one_shot_with_backend(
735            &backend,
736            OneShotRequest {
737                agent_kind: AgentKind::Codex,
738                child_pid: None,
739                folder: temp_directory.path(),
740                model: AgentModel::Gpt55,
741                prompt: "Generate title",
742                request_kind: AgentRequestKind::UtilityPrompt,
743                reasoning_level: ReasoningLevel::default(),
744            },
745        )
746        .await
747        .expect_err("blank utility output should fail");
748
749        // Assert
750        assert!(error.contains("did not match the required JSON schema"));
751        assert!(error.contains("trimmed_len: 0 chars"));
752        assert!(error.contains("response:\n"));
753    }
754
755    #[tokio::test]
756    /// Verifies one-shot execution does not deadlock when the child delays
757    /// reading stdin until after it emits early stderr output.
758    async fn test_submit_one_shot_with_backend_writes_large_stdin_concurrently() {
759        // Arrange
760        let temp_directory = tempdir().expect("failed to create temp dir");
761        let large_prompt = "x".repeat(512 * 1024);
762        let mut backend = MockAgentBackend::new();
763        backend.expect_build_command().returning(|_| {
764            let mut command = Command::new("sh");
765            command.arg("-c").arg(
766                "printf 'warming up\\n' >&2; sleep 0.1; cat >/dev/null; printf '%s' \
767                 '{\"answer\":\"done\",\"questions\":[],\"summary\":null}'",
768            );
769            command.stdout(std::process::Stdio::piped());
770            command.stderr(std::process::Stdio::piped());
771
772            Ok(command)
773        });
774
775        // Act
776        let response = tokio::time::timeout(
777            Duration::from_secs(5),
778            submit_one_shot_with_backend(
779                &backend,
780                OneShotRequest {
781                    agent_kind: AgentKind::Claude,
782                    child_pid: None,
783                    folder: temp_directory.path(),
784                    model: AgentModel::ClaudeSonnet5,
785                    prompt: &large_prompt,
786                    request_kind: AgentRequestKind::UtilityPrompt,
787                    reasoning_level: ReasoningLevel::default(),
788                },
789            ),
790        )
791        .await
792        .expect("one-shot prompt should not deadlock")
793        .expect("one-shot prompt should succeed");
794
795        // Assert
796        assert_eq!(response.response.answers(), vec!["done".to_string()]);
797    }
798
799    #[tokio::test]
800    /// Verifies one-shot execution streams Claude prompts through stdin so
801    /// large review requests avoid argv length limits.
802    async fn test_submit_one_shot_with_backend_writes_prompt_to_stdin() {
803        // Arrange
804        let temp_directory = tempdir().expect("failed to create temp dir");
805        let capture_path = temp_directory.path().join("stdin.txt");
806        let mut backend = MockAgentBackend::new();
807        backend.expect_build_command().returning({
808            let capture_path = capture_path.clone();
809
810            move |_| Ok(stdin_capture_shell_command(&capture_path))
811        });
812
813        // Act
814        let response = submit_one_shot_with_backend(
815            &backend,
816            OneShotRequest {
817                agent_kind: AgentKind::Claude,
818                child_pid: None,
819                folder: temp_directory.path(),
820                model: AgentModel::ClaudeSonnet5,
821                prompt: "Generate title",
822                request_kind: AgentRequestKind::UtilityPrompt,
823                reasoning_level: ReasoningLevel::default(),
824            },
825        )
826        .await
827        .expect("one-shot prompt should succeed");
828        let captured_prompt =
829            std::fs::read_to_string(&capture_path).expect("captured stdin payload should exist");
830
831        // Assert
832        assert_eq!(response.response.answers(), vec!["captured".to_string()]);
833        assert!(captured_prompt.contains("Structured response protocol:"));
834        assert!(captured_prompt.contains("Generate title"));
835    }
836
837    #[tokio::test]
838    /// Verifies a broken stdin pipe does not hide the child exit status or
839    /// stderr when the backend exits before reading the full prompt.
840    async fn test_submit_one_shot_with_backend_preserves_exit_error_after_broken_pipe() {
841        // Arrange
842        let temp_directory = tempdir().expect("failed to create temp dir");
843        let large_prompt = "x".repeat(512 * 1024);
844        let mut backend = MockAgentBackend::new();
845        backend.expect_build_command().returning(|_| {
846            let mut command = Command::new("sh");
847            command.arg("-c").arg("printf 'auth failed' >&2; exit 7");
848            command.stdout(std::process::Stdio::piped());
849            command.stderr(std::process::Stdio::piped());
850
851            Ok(command)
852        });
853
854        // Act
855        let error = submit_one_shot_with_backend(
856            &backend,
857            OneShotRequest {
858                agent_kind: AgentKind::Claude,
859                child_pid: None,
860                folder: temp_directory.path(),
861                model: AgentModel::ClaudeSonnet5,
862                prompt: &large_prompt,
863                request_kind: AgentRequestKind::UtilityPrompt,
864                reasoning_level: ReasoningLevel::default(),
865            },
866        )
867        .await
868        .expect_err("one-shot prompt should surface the child exit");
869
870        // Assert
871        assert!(error.contains("exit code 7"), "error was: {error}");
872        assert!(error.contains("auth failed"), "error was: {error}");
873        assert!(
874            !error.contains("stdin payload"),
875            "stdin write error should not mask child failure: {error}"
876        );
877    }
878
879    #[tokio::test]
880    /// Verifies Claude authentication failures return actionable re-login
881    /// guidance instead of raw transport output.
882    async fn test_submit_one_shot_with_backend_surfaces_claude_auth_guidance() {
883        // Arrange
884        let temp_directory = tempdir().expect("failed to create temp dir");
885        let mut backend = MockAgentBackend::new();
886        backend.expect_build_command().returning(|_| {
887            Ok(mock_shell_command(
888                r#"{"type":"error","error":{"type":"authentication_error","message":"OAuth token has expired. Please obtain a new token or refresh your existing token."}}"#,
889                "",
890                1,
891            ))
892        });
893
894        // Act
895        let error = submit_one_shot_with_backend(
896            &backend,
897            OneShotRequest {
898                agent_kind: AgentKind::Claude,
899                child_pid: None,
900                folder: temp_directory.path(),
901                model: AgentModel::ClaudeSonnet5,
902                prompt: "Generate title",
903                request_kind: AgentRequestKind::UtilityPrompt,
904                reasoning_level: ReasoningLevel::default(),
905            },
906        )
907        .await
908        .expect_err("expired Claude auth should fail");
909
910        // Assert
911        assert!(
912            error.contains("One-shot agent command failed because Claude authentication expired")
913        );
914        assert!(error.contains("`claude auth login`"));
915        assert!(error.contains("`claude auth status`"));
916    }
917
918    #[tokio::test]
919    /// Verifies app-server-backed one-shot execution returns the parsed
920    /// structured answer and usage totals.
921    async fn test_submit_one_shot_with_app_server_client_returns_protocol_response() {
922        // Arrange
923        let temp_directory = tempdir().expect("failed to create temp dir");
924        let mut app_server_client = MockAppServerClient::new();
925        app_server_client
926            .expect_run_turn()
927            .times(1)
928            .returning(|request, _| {
929                assert_eq!(request.model, AgentModel::Gpt55.as_str());
930                assert!(matches!(
931                    request.request_kind,
932                    AgentRequestKind::UtilityPrompt
933                ));
934                assert_eq!(request.prompt.text, "Generate title");
935
936                Box::pin(async {
937                    Ok(AppServerTurnResponse {
938                        assistant_message:
939                            r#"{"answer":"Generated title","questions":[],"summary":null}"#
940                                .to_string(),
941                        context_reset: false,
942                        input_tokens: 11,
943                        output_tokens: 7,
944                        pid: Some(42),
945                        provider_conversation_id: Some("thread-1".to_string()),
946                    })
947                })
948            });
949        app_server_client
950            .expect_shutdown_session()
951            .times(1)
952            .returning(|_| Box::pin(async {}));
953
954        // Act
955        let response = submit_one_shot_with_app_server_client(
956            &app_server_client,
957            OneShotRequest {
958                agent_kind: AgentKind::Codex,
959                child_pid: None,
960                folder: temp_directory.path(),
961                model: AgentModel::Gpt55,
962                prompt: "Generate title",
963                request_kind: AgentRequestKind::UtilityPrompt,
964                reasoning_level: ReasoningLevel::default(),
965            },
966        )
967        .await
968        .expect("one-shot prompt should succeed");
969
970        // Assert
971        assert_eq!(
972            response.response.answers(),
973            vec!["Generated title".to_string()]
974        );
975        assert_eq!(response.stats.input_tokens, 11);
976        assert_eq!(response.stats.output_tokens, 7);
977    }
978
979    #[tokio::test]
980    /// Verifies app-server-backed one-shot execution rejects plain-text
981    /// utility output after both the original parse and the protocol-repair
982    /// retry fail.
983    async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_utility_output() {
984        // Arrange
985        let temp_directory = tempdir().expect("failed to create temp dir");
986        let mut app_server_client = MockAppServerClient::new();
987        app_server_client
988            .expect_run_turn()
989            .times(2)
990            .returning(|request, _| {
991                assert_eq!(request.model, AgentModel::Gpt55.as_str());
992
993                Box::pin(async {
994                    Ok(AppServerTurnResponse {
995                        assistant_message: "plain text".to_string(),
996                        context_reset: false,
997                        input_tokens: 2,
998                        output_tokens: 1,
999                        pid: None,
1000                        provider_conversation_id: None,
1001                    })
1002                })
1003            });
1004        app_server_client
1005            .expect_shutdown_session()
1006            .times(1)
1007            .returning(|_| Box::pin(async {}));
1008
1009        // Act
1010        let error = submit_one_shot_with_app_server_client(
1011            &app_server_client,
1012            OneShotRequest {
1013                agent_kind: AgentKind::Codex,
1014                child_pid: None,
1015                folder: temp_directory.path(),
1016                model: AgentModel::Gpt55,
1017                prompt: "Generate title",
1018                request_kind: AgentRequestKind::UtilityPrompt,
1019                reasoning_level: ReasoningLevel::default(),
1020            },
1021        )
1022        .await
1023        .expect_err("plain-text utility output should fail");
1024
1025        // Assert
1026        assert!(error.contains("did not match the required JSON schema"));
1027        assert!(error.contains("debug_details:"));
1028        assert!(error.contains("response:\nplain text"));
1029    }
1030
1031    #[tokio::test]
1032    /// Verifies app-server-backed non-utility one-shot execution still
1033    /// rejects plain-text output after both the original parse and the
1034    /// protocol-repair retry fail.
1035    async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_non_utility_output() {
1036        // Arrange
1037        let temp_directory = tempdir().expect("failed to create temp dir");
1038        let mut app_server_client = MockAppServerClient::new();
1039        app_server_client
1040            .expect_run_turn()
1041            .times(2)
1042            .returning(|request, _| {
1043                assert!(matches!(
1044                    request.request_kind,
1045                    AgentRequestKind::SessionStart
1046                ));
1047
1048                Box::pin(async {
1049                    Ok(AppServerTurnResponse {
1050                        assistant_message: "plain text".to_string(),
1051                        context_reset: false,
1052                        input_tokens: 2,
1053                        output_tokens: 1,
1054                        pid: None,
1055                        provider_conversation_id: None,
1056                    })
1057                })
1058            });
1059        app_server_client
1060            .expect_shutdown_session()
1061            .times(1)
1062            .returning(|_| Box::pin(async {}));
1063
1064        // Act
1065        let error = submit_one_shot_with_app_server_client(
1066            &app_server_client,
1067            OneShotRequest {
1068                agent_kind: AgentKind::Codex,
1069                child_pid: None,
1070                folder: temp_directory.path(),
1071                model: AgentModel::Gpt55,
1072                prompt: "Generate title",
1073                request_kind: AgentRequestKind::SessionStart,
1074                reasoning_level: ReasoningLevel::default(),
1075            },
1076        )
1077        .await
1078        .expect_err("invalid non-utility output should fail");
1079
1080        // Assert
1081        assert!(error.contains("did not match the required JSON schema"));
1082        assert!(error.contains("debug_details:"));
1083        assert!(error.contains("response:\nplain text"));
1084    }
1085}