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