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