1use std::os::unix::process::ExitStatusExt as _;
9use std::path::Path;
10use std::sync::{Arc, Mutex};
11
12use super::backend::{AgentBackend, BuildCommandRequest};
13use super::cli::{error, stdin};
14use super::protocol::{AgentResponse, parse_agent_response_strict};
15use super::{
16 ParsedResponse, create_app_server_client, create_backend, parse_response, transport_mode,
17};
18use crate::app_server::{AppServerClient, AppServerTurnRequest};
19use crate::channel::AgentRequestKind;
20use crate::model::agent::{AgentKind, AgentModel, ReasoningLevel};
21use crate::model::session::SessionStats;
22
23#[derive(Clone, Debug)]
26pub struct OneShotRequest<'a> {
27 pub agent_kind: AgentKind,
30 pub child_pid: Option<&'a Mutex<Option<u32>>>,
33 pub folder: &'a Path,
35 pub model: AgentModel,
37 pub prompt: &'a str,
39 pub request_kind: AgentRequestKind,
41 pub reasoning_level: ReasoningLevel,
43}
44
45#[derive(Clone, Debug, PartialEq)]
47pub struct OneShotSubmission {
48 pub response: AgentResponse,
50 pub stats: SessionStats,
52}
53
54pub async fn submit_one_shot(request: OneShotRequest<'_>) -> Result<AgentResponse, String> {
60 let submission = submit_one_shot_with_stats(request).await?;
61
62 Ok(submission.response)
63}
64
65pub(crate) async fn submit_one_shot_with_stats(
72 request: OneShotRequest<'_>,
73) -> Result<OneShotSubmission, String> {
74 submit_one_shot_with_stats_and_app_server_client(request, None).await
75}
76
77pub(crate) async fn submit_one_shot_with_stats_and_app_server_client(
85 request: OneShotRequest<'_>,
86 app_server_client_override: Option<Arc<dyn AppServerClient>>,
87) -> Result<OneShotSubmission, String> {
88 let backend = create_backend(request.agent_kind);
89
90 if transport_mode(request.agent_kind).uses_app_server() {
91 let app_server_client =
92 create_app_server_client(request.agent_kind, app_server_client_override).ok_or_else(
93 || {
94 format!(
95 "{} provider did not provide an app-server client",
96 request.agent_kind
97 )
98 },
99 )?;
100
101 return submit_one_shot_with_app_server_client(app_server_client.as_ref(), request).await;
102 }
103
104 submit_one_shot_with_backend(backend.as_ref(), request).await
105}
106
107pub async fn submit_one_shot_with_app_server_client(
117 app_server_client: &dyn AppServerClient,
118 request: OneShotRequest<'_>,
119) -> Result<OneShotSubmission, String> {
120 clear_child_pid_slot(request.child_pid);
121
122 let session_id = format!("one-shot-{}", uuid::Uuid::new_v4());
123 let (stream_tx, _stream_rx) = tokio::sync::mpsc::unbounded_channel();
124 let turn_request = AppServerTurnRequest {
125 folder: request.folder.to_path_buf(),
126 live_session_output: None,
127 model: request.model.provider_model_str().to_string(),
128 prompt: crate::model::turn_prompt::TurnPrompt::from_agent_data(request.prompt.to_string()),
129 request_kind: request.request_kind.clone(),
130 provider_conversation_id: None,
131 persisted_instruction_conversation_id: None,
132 reasoning_level: request.reasoning_level,
133 session_id: session_id.clone(),
134 };
135
136 let turn_result = app_server_client.run_turn(turn_request, stream_tx).await;
137
138 let child_pid = request.child_pid;
139
140 let turn_result = match turn_result {
141 Ok(result) => result,
142 Err(error) => {
143 app_server_client.shutdown_session(session_id).await;
144 clear_child_pid_slot(child_pid);
145
146 return Err(format!(
147 "Failed to execute one-shot app-server turn: {error}"
148 ));
149 }
150 };
151
152 let parse_result = match parse_one_shot_response(&turn_result.assistant_message) {
153 Ok(response) => Ok((response, 0, 0)),
154 Err(parse_error) => {
155 attempt_one_shot_app_server_repair(
156 app_server_client,
157 &parse_error,
158 &turn_result.assistant_message,
159 request,
160 &session_id,
161 turn_result.provider_conversation_id.as_deref(),
162 )
163 .await
164 }
165 };
166
167 app_server_client.shutdown_session(session_id).await;
168 clear_child_pid_slot(child_pid);
169
170 let (response, repair_input_tokens, repair_output_tokens) = parse_result?;
171
172 Ok(OneShotSubmission {
173 response,
174 stats: SessionStats {
175 added_lines: 0,
176 deleted_lines: 0,
177 input_tokens: turn_result.input_tokens + repair_input_tokens,
178 output_tokens: turn_result.output_tokens + repair_output_tokens,
179 },
180 })
181}
182
183pub async fn submit_one_shot_with_backend(
193 backend: &dyn AgentBackend,
194 request: OneShotRequest<'_>,
195) -> Result<OneShotSubmission, String> {
196 let parsed_response =
197 execute_one_shot_command(backend, request.prompt, request.clone()).await?;
198 let (agent_response, repair_stats) = match parse_one_shot_response(&parsed_response.content) {
199 Ok(response) => (response, None),
200 Err(parse_error) => {
201 let repair_prompt = super::protocol::build_protocol_repair_prompt(
202 &parse_error,
203 &parsed_response.content,
204 );
205 let repair_response = execute_one_shot_command(backend, &repair_prompt, request)
206 .await
207 .map_err(|error| format!("{parse_error}\nrepair transport failed: {error}"))?;
208
209 let response = parse_one_shot_response(&repair_response.content).map_err(|error| {
210 format!(
211 "{parse_error}\nrepair retry also failed: {error}\nrepair_response:\n{}",
212 repair_response.content
213 )
214 })?;
215
216 (response, Some(repair_response.stats))
217 }
218 };
219
220 let mut stats = parsed_response.stats;
221 if let Some(repair) = repair_stats {
222 stats.input_tokens += repair.input_tokens;
223 stats.output_tokens += repair.output_tokens;
224 }
225
226 Ok(OneShotSubmission {
227 response: agent_response,
228 stats,
229 })
230}
231
232fn parse_one_shot_response(content: &str) -> Result<AgentResponse, String> {
238 parse_agent_response_strict(content).map_err(|error| {
239 format!(
240 "One-shot agent output did not match the required JSON schema: \
241 {error}\ndebug_details:\n{}\nresponse:\n{content}",
242 super::protocol::format_protocol_parse_debug_details(content)
243 )
244 })
245}
246
247async fn attempt_one_shot_app_server_repair(
261 app_server_client: &dyn AppServerClient,
262 parse_error: &str,
263 malformed_response: &str,
264 request: OneShotRequest<'_>,
265 session_id: &str,
266 provider_conversation_id: Option<&str>,
267) -> Result<(AgentResponse, u64, u64), String> {
268 let repair_prompt =
269 super::protocol::build_protocol_repair_prompt(parse_error, malformed_response);
270
271 let (repair_stream_tx, _repair_stream_rx) = tokio::sync::mpsc::unbounded_channel();
272 let repair_turn_request = AppServerTurnRequest {
273 folder: request.folder.to_path_buf(),
274 live_session_output: None,
275 model: request.model.provider_model_str().to_string(),
276 prompt: crate::model::turn_prompt::TurnPrompt::from_agent_data(repair_prompt),
277 request_kind: request.request_kind,
278 provider_conversation_id: provider_conversation_id.map(String::from),
279 persisted_instruction_conversation_id: None,
280 reasoning_level: request.reasoning_level,
281 session_id: session_id.to_string(),
282 };
283 let repair_result = app_server_client
284 .run_turn(repair_turn_request, repair_stream_tx)
285 .await
286 .map_err(|error| format!("{parse_error}\nrepair transport failed: {error}"))?;
287
288 let response = parse_one_shot_response(&repair_result.assistant_message).map_err(|error| {
289 format!(
290 "{parse_error}\nrepair retry also failed: {error}\nrepair_response:\n{}",
291 repair_result.assistant_message
292 )
293 })?;
294
295 Ok((
296 response,
297 repair_result.input_tokens,
298 repair_result.output_tokens,
299 ))
300}
301
302async fn execute_one_shot_command(
312 backend: &dyn AgentBackend,
313 prompt: &str,
314 request: OneShotRequest<'_>,
315) -> Result<ParsedResponse, String> {
316 let prompt_payload = crate::model::turn_prompt::TurnPrompt::from_agent_data(prompt.to_string());
317 let build_request = BuildCommandRequest {
318 attachments: &prompt_payload.attachments,
319 folder: request.folder,
320 prompt,
321 request_kind: &request.request_kind,
322 model: request.model.provider_model_str(),
323 reasoning_level: request.reasoning_level,
324 };
325 let command = backend
326 .build_command(build_request)
327 .map_err(|error| format!("Failed to build one-shot agent command: {error}"))?;
328 let stdin_payload = super::build_command_stdin_payload(request.agent_kind, build_request)
329 .map_err(|error| format!("Failed to build one-shot agent stdin payload: {error}"))?;
330 let mut tokio_command = tokio::process::Command::from(command);
331 tokio_command
332 .stdin(if stdin_payload.is_some() {
333 std::process::Stdio::piped()
334 } else {
335 std::process::Stdio::null()
336 })
337 .kill_on_drop(true);
338 let mut pid_guard = ChildPidGuard::new(request.child_pid);
339 let mut child = tokio_command
340 .spawn()
341 .map_err(|error| format!("Failed to execute one-shot agent command: {error}"))?;
342 pid_guard.update_from_child(&child);
343 let stdin_write_task = stdin::spawn_optional_stdin_write(
344 child.stdin.take(),
345 stdin_payload,
346 "one-shot stdin pipe unavailable after spawn",
347 std::convert::identity,
348 );
349 let output = child
350 .wait_with_output()
351 .await
352 .map_err(|error| format!("Failed to execute one-shot agent command: {error}"))?;
353 stdin::await_optional_stdin_write(
354 stdin_write_task,
355 "One-shot stdin write task failed",
356 std::convert::identity,
357 )
358 .await?;
359
360 if output.status.signal().is_some() {
361 return Err("One-shot agent command was interrupted".to_string());
362 }
363
364 let stdout_text = String::from_utf8_lossy(&output.stdout).into_owned();
365 let stderr_text = String::from_utf8_lossy(&output.stderr).into_owned();
366 if !output.status.success() {
367 return Err(format_one_shot_exit_error(
368 request.agent_kind,
369 output.status.code(),
370 &stdout_text,
371 &stderr_text,
372 ));
373 }
374
375 let parsed_response = parse_response(request.agent_kind, &stdout_text, &stderr_text);
376
377 Ok(parsed_response)
378}
379
380fn format_one_shot_exit_error(
382 agent_kind: AgentKind,
383 exit_code: Option<i32>,
384 stdout: &str,
385 stderr: &str,
386) -> String {
387 error::format_agent_cli_exit_error(
388 agent_kind,
389 "One-shot agent command",
390 exit_code,
391 stdout,
392 stderr,
393 )
394}
395
396fn clear_child_pid_slot(child_pid: Option<&Mutex<Option<u32>>>) {
398 let Some(child_pid) = child_pid else {
399 return;
400 };
401
402 if let Ok(mut guard) = child_pid.lock() {
403 *guard = None;
404 }
405}
406
407struct ChildPidGuard<'a> {
409 child_pid: Option<&'a Mutex<Option<u32>>>,
410}
411
412impl<'a> ChildPidGuard<'a> {
413 fn new(child_pid: Option<&'a Mutex<Option<u32>>>) -> Self {
415 Self { child_pid }
416 }
417
418 fn update_from_child(&mut self, child: &tokio::process::Child) {
420 let Some(pid) = child.id() else {
421 return;
422 };
423
424 let Some(child_pid) = self.child_pid else {
425 return;
426 };
427
428 if let Ok(mut guard) = child_pid.lock() {
429 *guard = Some(pid);
430 }
431 }
432}
433
434impl Drop for ChildPidGuard<'_> {
435 fn drop(&mut self) {
436 let Some(child_pid) = self.child_pid else {
437 return;
438 };
439
440 if let Ok(mut guard) = child_pid.lock() {
441 *guard = None;
442 }
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use std::process::Command;
449 use std::time::Duration;
450
451 use tempfile::tempdir;
452
453 use super::*;
454 use crate::agent::tests::MockAgentBackend;
455 use crate::app_server::{AppServerTurnResponse, MockAppServerClient};
456
457 fn mock_shell_command(stdout: &str, stderr: &str, exit_code: i32) -> Command {
459 let mut command = Command::new("sh");
460 command.arg("-c").arg(
461 "printf '%s' \"$ONE_SHOT_STDOUT\"; printf '%s' \"$ONE_SHOT_STDERR\" >&2; exit \
462 \"$ONE_SHOT_EXIT\"",
463 );
464 command.env("ONE_SHOT_STDOUT", stdout);
465 command.env("ONE_SHOT_STDERR", stderr);
466 command.env("ONE_SHOT_EXIT", exit_code.to_string());
467 command.stdout(std::process::Stdio::piped());
468 command.stderr(std::process::Stdio::piped());
469
470 command
471 }
472
473 fn stdin_capture_shell_command(capture_path: &Path) -> Command {
475 let mut command = Command::new("sh");
476 command.arg("-c").arg(
477 "cat > \"$ONE_SHOT_CAPTURE_PATH\"; printf '%s' \
478 '{\"answer\":\"captured\",\"questions\":[],\"summary\":null}'",
479 );
480 command.env("ONE_SHOT_CAPTURE_PATH", capture_path);
481 command.stdout(std::process::Stdio::piped());
482 command.stderr(std::process::Stdio::piped());
483
484 command
485 }
486
487 #[tokio::test]
488 async fn test_submit_one_shot_with_backend_returns_protocol_response() {
490 let temp_directory = tempdir().expect("failed to create temp dir");
492 let mut backend = MockAgentBackend::new();
493 backend.expect_build_command().returning(|request| {
494 assert!(matches!(
495 request.request_kind,
496 AgentRequestKind::UtilityPrompt
497 ));
498 assert_eq!(request.prompt, "Generate title");
499
500 Ok(mock_shell_command(
501 r#"{"answer":"Generated title","questions":[],"summary":null}"#,
502 "",
503 0,
504 ))
505 });
506
507 let response = submit_one_shot_with_backend(
509 &backend,
510 OneShotRequest {
511 agent_kind: AgentKind::Claude,
512 child_pid: None,
513 folder: temp_directory.path(),
514 model: AgentModel::ClaudeSonnet5,
515 prompt: "Generate title",
516 request_kind: AgentRequestKind::UtilityPrompt,
517 reasoning_level: ReasoningLevel::default(),
518 },
519 )
520 .await
521 .expect("one-shot prompt should succeed");
522
523 assert_eq!(
525 response.response.answers(),
526 vec!["Generated title".to_string()]
527 );
528 }
529
530 #[tokio::test]
531 async fn test_submit_one_shot_with_backend_rejects_plain_text_utility_output() {
534 let temp_directory = tempdir().expect("failed to create temp dir");
536 let mut backend = MockAgentBackend::new();
537 backend
538 .expect_build_command()
539 .times(2)
540 .returning(|request| {
541 assert!(matches!(
542 request.request_kind,
543 AgentRequestKind::UtilityPrompt
544 ));
545
546 Ok(mock_shell_command("plain text", "", 0))
547 });
548
549 let error = submit_one_shot_with_backend(
551 &backend,
552 OneShotRequest {
553 agent_kind: AgentKind::Codex,
554 child_pid: None,
555 folder: temp_directory.path(),
556 model: AgentModel::Gpt55,
557 prompt: "Generate title",
558 request_kind: AgentRequestKind::UtilityPrompt,
559 reasoning_level: ReasoningLevel::default(),
560 },
561 )
562 .await
563 .expect_err("plain-text utility output should fail");
564
565 assert!(error.contains("did not match the required JSON schema"));
567 assert!(error.contains("debug_details:"));
568 assert!(error.contains("direct_json_error_location: line 1, column 1"));
569 assert!(error.contains("response:\nplain text"));
570 }
571
572 #[tokio::test]
573 async fn test_submit_one_shot_with_backend_rejects_wrapped_plain_text_utility_output() {
576 let temp_directory = tempdir().expect("failed to create temp dir");
578 let mut backend = MockAgentBackend::new();
579 backend
580 .expect_build_command()
581 .times(2)
582 .returning(|request| {
583 assert!(matches!(
584 request.request_kind,
585 AgentRequestKind::UtilityPrompt
586 ));
587
588 Ok(mock_shell_command(
589 r#"{"result":"plain text","usage":{"input_tokens":2,"output_tokens":1}}"#,
590 "",
591 0,
592 ))
593 });
594
595 let error = submit_one_shot_with_backend(
597 &backend,
598 OneShotRequest {
599 agent_kind: AgentKind::Claude,
600 child_pid: None,
601 folder: temp_directory.path(),
602 model: AgentModel::ClaudeSonnet5,
603 prompt: "Generate title",
604 request_kind: AgentRequestKind::UtilityPrompt,
605 reasoning_level: ReasoningLevel::default(),
606 },
607 )
608 .await
609 .expect_err("wrapped plain-text utility output should fail");
610
611 assert!(error.contains("did not match the required JSON schema"));
614 assert!(error.contains("direct_json_error:"));
615 assert!(error.contains("response:\nplain text"));
616 }
617
618 #[tokio::test]
619 async fn test_submit_one_shot_with_backend_recovers_wrapped_protocol_output() {
622 let temp_directory = tempdir().expect("failed to create temp dir");
624 let mut backend = MockAgentBackend::new();
625 backend
626 .expect_build_command()
627 .times(1)
628 .returning(|request| {
629 assert!(matches!(
630 request.request_kind,
631 AgentRequestKind::UtilityPrompt
632 ));
633 assert_eq!(request.prompt, "Generate title");
634
635 Ok(mock_shell_command(
636 concat!(
637 "Now I have full context.\n",
638 r#"{"answer":"Generated title","questions":[],"summary":null}"#
639 ),
640 "",
641 0,
642 ))
643 });
644
645 let response = submit_one_shot_with_backend(
647 &backend,
648 OneShotRequest {
649 agent_kind: AgentKind::Claude,
650 child_pid: None,
651 folder: temp_directory.path(),
652 model: AgentModel::ClaudeSonnet5,
653 prompt: "Generate title",
654 request_kind: AgentRequestKind::UtilityPrompt,
655 reasoning_level: ReasoningLevel::default(),
656 },
657 )
658 .await
659 .expect("wrapped protocol output should succeed");
660
661 assert_eq!(
663 response.response.answers(),
664 vec!["Generated title".to_string()]
665 );
666 }
667
668 #[tokio::test]
669 async fn test_submit_one_shot_with_backend_recovers_via_protocol_repair() {
672 let temp_directory = tempdir().expect("failed to create temp dir");
674 let call_counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
675 let mut backend = MockAgentBackend::new();
676 backend.expect_build_command().times(2).returning({
677 let counter = std::sync::Arc::clone(&call_counter);
678
679 move |_| {
680 let call_number = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
681
682 if call_number == 0 {
683 Ok(mock_shell_command("plain text", "", 0))
684 } else {
685 Ok(mock_shell_command(
686 r#"{"answer":"Repaired title","questions":[],"summary":null}"#,
687 "",
688 0,
689 ))
690 }
691 }
692 });
693
694 let response = submit_one_shot_with_backend(
696 &backend,
697 OneShotRequest {
698 agent_kind: AgentKind::Codex,
699 child_pid: None,
700 folder: temp_directory.path(),
701 model: AgentModel::Gpt55,
702 prompt: "Generate title",
703 request_kind: AgentRequestKind::UtilityPrompt,
704 reasoning_level: ReasoningLevel::default(),
705 },
706 )
707 .await
708 .expect("repair retry should succeed");
709
710 assert_eq!(
712 response.response.answers(),
713 vec!["Repaired title".to_string()]
714 );
715 }
716
717 #[tokio::test]
718 async fn test_submit_one_shot_with_backend_rejects_blank_utility_output() {
721 let temp_directory = tempdir().expect("failed to create temp dir");
723 let mut backend = MockAgentBackend::new();
724 backend.expect_build_command().returning(|request| {
725 assert!(matches!(
726 request.request_kind,
727 AgentRequestKind::UtilityPrompt
728 ));
729
730 Ok(mock_shell_command(" ", "", 0))
731 });
732
733 let error = submit_one_shot_with_backend(
735 &backend,
736 OneShotRequest {
737 agent_kind: AgentKind::Codex,
738 child_pid: None,
739 folder: temp_directory.path(),
740 model: AgentModel::Gpt55,
741 prompt: "Generate title",
742 request_kind: AgentRequestKind::UtilityPrompt,
743 reasoning_level: ReasoningLevel::default(),
744 },
745 )
746 .await
747 .expect_err("blank utility output should fail");
748
749 assert!(error.contains("did not match the required JSON schema"));
751 assert!(error.contains("trimmed_len: 0 chars"));
752 assert!(error.contains("response:\n"));
753 }
754
755 #[tokio::test]
756 async fn test_submit_one_shot_with_backend_writes_large_stdin_concurrently() {
759 let temp_directory = tempdir().expect("failed to create temp dir");
761 let large_prompt = "x".repeat(512 * 1024);
762 let mut backend = MockAgentBackend::new();
763 backend.expect_build_command().returning(|_| {
764 let mut command = Command::new("sh");
765 command.arg("-c").arg(
766 "printf 'warming up\\n' >&2; sleep 0.1; cat >/dev/null; printf '%s' \
767 '{\"answer\":\"done\",\"questions\":[],\"summary\":null}'",
768 );
769 command.stdout(std::process::Stdio::piped());
770 command.stderr(std::process::Stdio::piped());
771
772 Ok(command)
773 });
774
775 let response = tokio::time::timeout(
777 Duration::from_secs(5),
778 submit_one_shot_with_backend(
779 &backend,
780 OneShotRequest {
781 agent_kind: AgentKind::Claude,
782 child_pid: None,
783 folder: temp_directory.path(),
784 model: AgentModel::ClaudeSonnet5,
785 prompt: &large_prompt,
786 request_kind: AgentRequestKind::UtilityPrompt,
787 reasoning_level: ReasoningLevel::default(),
788 },
789 ),
790 )
791 .await
792 .expect("one-shot prompt should not deadlock")
793 .expect("one-shot prompt should succeed");
794
795 assert_eq!(response.response.answers(), vec!["done".to_string()]);
797 }
798
799 #[tokio::test]
800 async fn test_submit_one_shot_with_backend_writes_prompt_to_stdin() {
803 let temp_directory = tempdir().expect("failed to create temp dir");
805 let capture_path = temp_directory.path().join("stdin.txt");
806 let mut backend = MockAgentBackend::new();
807 backend.expect_build_command().returning({
808 let capture_path = capture_path.clone();
809
810 move |_| Ok(stdin_capture_shell_command(&capture_path))
811 });
812
813 let response = submit_one_shot_with_backend(
815 &backend,
816 OneShotRequest {
817 agent_kind: AgentKind::Claude,
818 child_pid: None,
819 folder: temp_directory.path(),
820 model: AgentModel::ClaudeSonnet5,
821 prompt: "Generate title",
822 request_kind: AgentRequestKind::UtilityPrompt,
823 reasoning_level: ReasoningLevel::default(),
824 },
825 )
826 .await
827 .expect("one-shot prompt should succeed");
828 let captured_prompt =
829 std::fs::read_to_string(&capture_path).expect("captured stdin payload should exist");
830
831 assert_eq!(response.response.answers(), vec!["captured".to_string()]);
833 assert!(captured_prompt.contains("Structured response protocol:"));
834 assert!(captured_prompt.contains("Generate title"));
835 }
836
837 #[tokio::test]
838 async fn test_submit_one_shot_with_backend_preserves_exit_error_after_broken_pipe() {
841 let temp_directory = tempdir().expect("failed to create temp dir");
843 let large_prompt = "x".repeat(512 * 1024);
844 let mut backend = MockAgentBackend::new();
845 backend.expect_build_command().returning(|_| {
846 let mut command = Command::new("sh");
847 command.arg("-c").arg("printf 'auth failed' >&2; exit 7");
848 command.stdout(std::process::Stdio::piped());
849 command.stderr(std::process::Stdio::piped());
850
851 Ok(command)
852 });
853
854 let error = submit_one_shot_with_backend(
856 &backend,
857 OneShotRequest {
858 agent_kind: AgentKind::Claude,
859 child_pid: None,
860 folder: temp_directory.path(),
861 model: AgentModel::ClaudeSonnet5,
862 prompt: &large_prompt,
863 request_kind: AgentRequestKind::UtilityPrompt,
864 reasoning_level: ReasoningLevel::default(),
865 },
866 )
867 .await
868 .expect_err("one-shot prompt should surface the child exit");
869
870 assert!(error.contains("exit code 7"), "error was: {error}");
872 assert!(error.contains("auth failed"), "error was: {error}");
873 assert!(
874 !error.contains("stdin payload"),
875 "stdin write error should not mask child failure: {error}"
876 );
877 }
878
879 #[tokio::test]
880 async fn test_submit_one_shot_with_backend_surfaces_claude_auth_guidance() {
883 let temp_directory = tempdir().expect("failed to create temp dir");
885 let mut backend = MockAgentBackend::new();
886 backend.expect_build_command().returning(|_| {
887 Ok(mock_shell_command(
888 r#"{"type":"error","error":{"type":"authentication_error","message":"OAuth token has expired. Please obtain a new token or refresh your existing token."}}"#,
889 "",
890 1,
891 ))
892 });
893
894 let error = submit_one_shot_with_backend(
896 &backend,
897 OneShotRequest {
898 agent_kind: AgentKind::Claude,
899 child_pid: None,
900 folder: temp_directory.path(),
901 model: AgentModel::ClaudeSonnet5,
902 prompt: "Generate title",
903 request_kind: AgentRequestKind::UtilityPrompt,
904 reasoning_level: ReasoningLevel::default(),
905 },
906 )
907 .await
908 .expect_err("expired Claude auth should fail");
909
910 assert!(
912 error.contains("One-shot agent command failed because Claude authentication expired")
913 );
914 assert!(error.contains("`claude auth login`"));
915 assert!(error.contains("`claude auth status`"));
916 }
917
918 #[tokio::test]
919 async fn test_submit_one_shot_with_app_server_client_returns_protocol_response() {
922 let temp_directory = tempdir().expect("failed to create temp dir");
924 let mut app_server_client = MockAppServerClient::new();
925 app_server_client
926 .expect_run_turn()
927 .times(1)
928 .returning(|request, _| {
929 assert_eq!(request.model, AgentModel::Gpt55.as_str());
930 assert!(matches!(
931 request.request_kind,
932 AgentRequestKind::UtilityPrompt
933 ));
934 assert_eq!(request.prompt.text, "Generate title");
935
936 Box::pin(async {
937 Ok(AppServerTurnResponse {
938 assistant_message:
939 r#"{"answer":"Generated title","questions":[],"summary":null}"#
940 .to_string(),
941 context_reset: false,
942 input_tokens: 11,
943 output_tokens: 7,
944 pid: Some(42),
945 provider_conversation_id: Some("thread-1".to_string()),
946 })
947 })
948 });
949 app_server_client
950 .expect_shutdown_session()
951 .times(1)
952 .returning(|_| Box::pin(async {}));
953
954 let response = submit_one_shot_with_app_server_client(
956 &app_server_client,
957 OneShotRequest {
958 agent_kind: AgentKind::Codex,
959 child_pid: None,
960 folder: temp_directory.path(),
961 model: AgentModel::Gpt55,
962 prompt: "Generate title",
963 request_kind: AgentRequestKind::UtilityPrompt,
964 reasoning_level: ReasoningLevel::default(),
965 },
966 )
967 .await
968 .expect("one-shot prompt should succeed");
969
970 assert_eq!(
972 response.response.answers(),
973 vec!["Generated title".to_string()]
974 );
975 assert_eq!(response.stats.input_tokens, 11);
976 assert_eq!(response.stats.output_tokens, 7);
977 }
978
979 #[tokio::test]
980 async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_utility_output() {
984 let temp_directory = tempdir().expect("failed to create temp dir");
986 let mut app_server_client = MockAppServerClient::new();
987 app_server_client
988 .expect_run_turn()
989 .times(2)
990 .returning(|request, _| {
991 assert_eq!(request.model, AgentModel::Gpt55.as_str());
992
993 Box::pin(async {
994 Ok(AppServerTurnResponse {
995 assistant_message: "plain text".to_string(),
996 context_reset: false,
997 input_tokens: 2,
998 output_tokens: 1,
999 pid: None,
1000 provider_conversation_id: None,
1001 })
1002 })
1003 });
1004 app_server_client
1005 .expect_shutdown_session()
1006 .times(1)
1007 .returning(|_| Box::pin(async {}));
1008
1009 let error = submit_one_shot_with_app_server_client(
1011 &app_server_client,
1012 OneShotRequest {
1013 agent_kind: AgentKind::Codex,
1014 child_pid: None,
1015 folder: temp_directory.path(),
1016 model: AgentModel::Gpt55,
1017 prompt: "Generate title",
1018 request_kind: AgentRequestKind::UtilityPrompt,
1019 reasoning_level: ReasoningLevel::default(),
1020 },
1021 )
1022 .await
1023 .expect_err("plain-text utility output should fail");
1024
1025 assert!(error.contains("did not match the required JSON schema"));
1027 assert!(error.contains("debug_details:"));
1028 assert!(error.contains("response:\nplain text"));
1029 }
1030
1031 #[tokio::test]
1032 async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_non_utility_output() {
1036 let temp_directory = tempdir().expect("failed to create temp dir");
1038 let mut app_server_client = MockAppServerClient::new();
1039 app_server_client
1040 .expect_run_turn()
1041 .times(2)
1042 .returning(|request, _| {
1043 assert!(matches!(
1044 request.request_kind,
1045 AgentRequestKind::SessionStart
1046 ));
1047
1048 Box::pin(async {
1049 Ok(AppServerTurnResponse {
1050 assistant_message: "plain text".to_string(),
1051 context_reset: false,
1052 input_tokens: 2,
1053 output_tokens: 1,
1054 pid: None,
1055 provider_conversation_id: None,
1056 })
1057 })
1058 });
1059 app_server_client
1060 .expect_shutdown_session()
1061 .times(1)
1062 .returning(|_| Box::pin(async {}));
1063
1064 let error = submit_one_shot_with_app_server_client(
1066 &app_server_client,
1067 OneShotRequest {
1068 agent_kind: AgentKind::Codex,
1069 child_pid: None,
1070 folder: temp_directory.path(),
1071 model: AgentModel::Gpt55,
1072 prompt: "Generate title",
1073 request_kind: AgentRequestKind::SessionStart,
1074 reasoning_level: ReasoningLevel::default(),
1075 },
1076 )
1077 .await
1078 .expect_err("invalid non-utility output should fail");
1079
1080 assert!(error.contains("did not match the required JSON schema"));
1082 assert!(error.contains("debug_details:"));
1083 assert!(error.contains("response:\nplain text"));
1084 }
1085}