1use 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#[derive(Clone, Debug)]
32pub struct OneShotRequest {
33 pub agent_kind: AgentKind,
36 pub child_pid: Option<Arc<Mutex<Option<u32>>>>,
39 pub folder: PathBuf,
41 pub model: AgentModel,
43 pub permission_mode: PermissionMode,
45 pub prompt: String,
47 pub request_kind: AgentRequestKind,
49 pub reasoning_level: ReasoningLevel,
51 pub speed_mode: SpeedMode,
53}
54
55#[derive(Clone, Debug, PartialEq)]
57pub struct OneShotSubmission {
58 pub response: AgentResponse,
60 pub stats: SessionStats,
62}
63
64#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
70#[error("{message}")]
71pub struct OneShotError {
72 message: String,
73}
74
75impl OneShotError {
76 pub fn new(message: impl Into<String>) -> Self {
78 Self {
79 message: message.into(),
80 }
81 }
82}
83
84#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
90#[async_trait]
91pub trait OneShotClient: Send + Sync {
92 async fn submit(&self, request: OneShotRequest) -> Result<OneShotSubmission, OneShotError>;
94}
95
96pub struct RealOneShotClient {
98 app_server_client_override: Option<Arc<dyn AppServerClient>>,
99}
100
101impl RealOneShotClient {
102 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
124async 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
154async 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
236async 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
283fn 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
299async 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
358async 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
414fn 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
427fn 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
443fn 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
454struct 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 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 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 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 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_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 let child_pid = Arc::new(Mutex::new(None));
550 let observer = OneShotCliObserver {
551 child_pid: Some(Arc::clone(&child_pid)),
552 };
553
554 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_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 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 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_eq!(error, "One-shot agent command was interrupted");
598 }
599
600 #[tokio::test]
601 async fn test_submit_one_shot_with_backend_returns_protocol_response() {
603 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 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_eq!(
641 response.response.answers(),
642 vec!["Generated title".to_string()]
643 );
644 }
645
646 #[tokio::test]
647 async fn test_submit_one_shot_with_backend_rejects_plain_text_utility_output() {
650 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 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!(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 async fn test_submit_one_shot_with_backend_rejects_wrapped_plain_text_utility_output() {
694 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 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!(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 async fn test_submit_one_shot_with_backend_recovers_wrapped_protocol_output() {
742 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 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_eq!(
785 response.response.answers(),
786 vec!["Generated title".to_string()]
787 );
788 }
789
790 #[tokio::test]
791 async fn test_submit_one_shot_with_backend_recovers_via_protocol_repair() {
794 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 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_eq!(
837 response.response.answers(),
838 vec!["Repaired title".to_string()]
839 );
840 }
841
842 #[tokio::test]
843 async fn test_submit_one_shot_with_backend_rejects_blank_utility_output() {
846 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 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!(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 async fn test_submit_one_shot_with_backend_writes_large_stdin_concurrently() {
886 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 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_eq!(response.response.answers(), vec!["done".to_string()]);
926 }
927
928 #[tokio::test]
929 async fn test_submit_one_shot_with_backend_writes_prompt_to_stdin() {
932 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 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_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 async fn test_submit_one_shot_with_backend_preserves_exit_error_after_broken_pipe() {
972 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 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!(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 async fn test_submit_one_shot_with_backend_surfaces_claude_auth_guidance() {
1016 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 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!(
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 async fn test_submit_one_shot_with_app_server_client_returns_protocol_response() {
1057 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 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_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 async fn test_submit_one_shot_with_app_server_client_clears_pid_after_turn_failure() {
1122 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 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!(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 async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_utility_output() {
1172 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 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!(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 async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_non_utility_output() {
1228 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 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!(error.contains("did not match the required JSON schema"));
1276 assert!(error.contains("debug_details:"));
1277 assert!(error.contains("response:\nplain text"));
1278 }
1279}