1use 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#[derive(Clone, Debug)]
30pub struct OneShotRequest<'a> {
31 pub agent_kind: AgentKind,
34 pub child_pid: Option<&'a Mutex<Option<u32>>>,
37 pub folder: &'a Path,
39 pub model: AgentModel,
41 pub prompt: &'a str,
43 pub request_kind: AgentRequestKind,
45 pub reasoning_level: ReasoningLevel,
47}
48
49#[derive(Clone, Debug, PartialEq)]
51pub struct OneShotSubmission {
52 pub response: AgentResponse,
54 pub stats: SessionStats,
56}
57
58pub 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
69pub(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
81pub(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
111pub 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
189pub 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
236fn 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
251async 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
307async 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
387fn 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
403fn 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
414struct ChildPidGuard<'a> {
416 child_pid: Option<&'a Mutex<Option<u32>>>,
417}
418
419impl<'a> ChildPidGuard<'a> {
420 fn new(child_pid: Option<&'a Mutex<Option<u32>>>) -> Self {
422 Self { child_pid }
423 }
424
425 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 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 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 async fn test_submit_one_shot_with_backend_returns_protocol_response() {
497 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 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_eq!(
532 response.response.answers(),
533 vec!["Generated title".to_string()]
534 );
535 }
536
537 #[tokio::test]
538 async fn test_submit_one_shot_with_backend_rejects_plain_text_utility_output() {
541 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 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!(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 async fn test_submit_one_shot_with_backend_rejects_wrapped_plain_text_utility_output() {
583 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 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!(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 async fn test_submit_one_shot_with_backend_recovers_wrapped_protocol_output() {
629 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 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_eq!(
670 response.response.answers(),
671 vec!["Generated title".to_string()]
672 );
673 }
674
675 #[tokio::test]
676 async fn test_submit_one_shot_with_backend_recovers_via_protocol_repair() {
679 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 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_eq!(
719 response.response.answers(),
720 vec!["Repaired title".to_string()]
721 );
722 }
723
724 #[tokio::test]
725 async fn test_submit_one_shot_with_backend_rejects_blank_utility_output() {
728 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 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!(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 async fn test_submit_one_shot_with_backend_writes_large_stdin_concurrently() {
766 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 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_eq!(response.response.answers(), vec!["done".to_string()]);
804 }
805
806 #[tokio::test]
807 async fn test_submit_one_shot_with_backend_writes_prompt_to_stdin() {
810 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 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_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 async fn test_submit_one_shot_with_backend_preserves_exit_error_after_broken_pipe() {
848 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 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!(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 async fn test_submit_one_shot_with_backend_surfaces_claude_auth_guidance() {
890 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 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!(
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 async fn test_submit_one_shot_with_app_server_client_returns_protocol_response() {
929 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 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_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 async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_utility_output() {
991 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 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!(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 async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_non_utility_output() {
1043 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 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!(error.contains("did not match the required JSON schema"));
1089 assert!(error.contains("debug_details:"));
1090 assert!(error.contains("response:\nplain text"));
1091 }
1092}