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> {
243 parse_agent_response_strict(content).map_err(|error| {
244 format!(
245 "One-shot agent output did not match the required JSON schema: \
246 {error}\ndebug_details:\n{}",
247 format_protocol_parse_debug_details(content)
248 )
249 })
250}
251
252async fn attempt_one_shot_app_server_repair(
266 app_server_client: &dyn AppServerClient,
267 parse_error: &str,
268 malformed_response: &str,
269 request: OneShotRequest<'_>,
270 session_id: &str,
271 provider_conversation_id: Option<&str>,
272) -> Result<(AgentResponse, u64, u64), String> {
273 let repair_prompt = build_protocol_repair_prompt(parse_error, malformed_response);
274
275 let (repair_stream_tx, _repair_stream_rx) = tokio::sync::mpsc::unbounded_channel();
276 let repair_turn_request = AppServerTurnRequest {
277 folder: request.folder.to_path_buf(),
278 live_transcript: None,
279 main_checkout_root: None,
280 model: request.model.provider_model_str().to_string(),
281 prompt: ag_protocol::TurnPrompt::from_agent_data(repair_prompt),
282 request_kind: request.request_kind,
283 replay_transcript: None,
284 provider_conversation_id: provider_conversation_id.map(String::from),
285 persisted_instruction_conversation_id: None,
286 reasoning_level: request.reasoning_level,
287 session_id: session_id.to_string(),
288 };
289 let repair_result = app_server_client
290 .run_turn(repair_turn_request, repair_stream_tx)
291 .await
292 .map_err(|error| format!("{parse_error}\nrepair transport failed: {error}"))?;
293
294 let response = parse_one_shot_response(&repair_result.assistant_message).map_err(|error| {
295 format!(
296 "{parse_error}\nrepair retry also failed: {error}\nrepair_response:\n{}",
297 repair_result.assistant_message
298 )
299 })?;
300
301 Ok((
302 response,
303 repair_result.input_tokens,
304 repair_result.output_tokens,
305 ))
306}
307
308async fn execute_one_shot_command(
318 backend: &dyn AgentBackend,
319 prompt: &str,
320 request: OneShotRequest<'_>,
321) -> Result<ParsedResponse, String> {
322 let prompt_payload = ag_protocol::TurnPrompt::from_agent_data(prompt.to_string());
323 let build_request = BuildCommandRequest {
324 attachments: &prompt_payload.attachments,
325 folder: request.folder,
326 main_checkout_root: None,
327 replay_transcript: None,
328 model: request.model.provider_model_str(),
329 prompt,
330 reasoning_level: request.reasoning_level,
331 request_kind: &request.request_kind,
332 };
333 let command = backend
334 .build_command(build_request)
335 .map_err(|error| format!("Failed to build one-shot agent command: {error}"))?;
336 let stdin_payload = super::build_command_stdin_payload(request.agent_kind, build_request)
337 .map_err(|error| format!("Failed to build one-shot agent stdin payload: {error}"))?;
338 let mut tokio_command = tokio::process::Command::from(command);
339 tokio_command
340 .stdin(if stdin_payload.is_some() {
341 std::process::Stdio::piped()
342 } else {
343 std::process::Stdio::null()
344 })
345 .kill_on_drop(true);
346 let mut pid_guard = ChildPidGuard::new(request.child_pid);
347 let mut child = tokio_command
348 .spawn()
349 .map_err(|error| format!("Failed to execute one-shot agent command: {error}"))?;
350 pid_guard.update_from_child(&child);
351 let stdin_write_task = stdin::spawn_optional_stdin_write(
352 child.stdin.take(),
353 stdin_payload,
354 "one-shot stdin pipe unavailable after spawn",
355 std::convert::identity,
356 );
357 let output = child
358 .wait_with_output()
359 .await
360 .map_err(|error| format!("Failed to execute one-shot agent command: {error}"))?;
361 stdin::await_optional_stdin_write(
362 stdin_write_task,
363 "One-shot stdin write task failed",
364 std::convert::identity,
365 )
366 .await?;
367
368 if output.status.signal().is_some() {
369 return Err("One-shot agent command was interrupted".to_string());
370 }
371
372 let stdout_text = String::from_utf8_lossy(&output.stdout).into_owned();
373 let stderr_text = String::from_utf8_lossy(&output.stderr).into_owned();
374 if !output.status.success() {
375 return Err(format_one_shot_exit_error(
376 request.agent_kind,
377 output.status.code(),
378 &stdout_text,
379 &stderr_text,
380 ));
381 }
382
383 let parsed_response = parse_response(request.agent_kind, &stdout_text, &stderr_text);
384
385 Ok(parsed_response)
386}
387
388fn format_one_shot_exit_error(
390 agent_kind: AgentKind,
391 exit_code: Option<i32>,
392 stdout: &str,
393 stderr: &str,
394) -> String {
395 error::format_agent_cli_exit_error(
396 agent_kind,
397 "One-shot agent command",
398 exit_code,
399 stdout,
400 stderr,
401 )
402}
403
404fn clear_child_pid_slot(child_pid: Option<&Mutex<Option<u32>>>) {
406 let Some(child_pid) = child_pid else {
407 return;
408 };
409
410 if let Ok(mut guard) = child_pid.lock() {
411 *guard = None;
412 }
413}
414
415struct ChildPidGuard<'a> {
417 child_pid: Option<&'a Mutex<Option<u32>>>,
418}
419
420impl<'a> ChildPidGuard<'a> {
421 fn new(child_pid: Option<&'a Mutex<Option<u32>>>) -> Self {
423 Self { child_pid }
424 }
425
426 fn update_from_child(&mut self, child: &tokio::process::Child) {
428 let Some(pid) = child.id() else {
429 return;
430 };
431
432 let Some(child_pid) = self.child_pid else {
433 return;
434 };
435
436 if let Ok(mut guard) = child_pid.lock() {
437 *guard = Some(pid);
438 }
439 }
440}
441
442impl Drop for ChildPidGuard<'_> {
443 fn drop(&mut self) {
444 let Some(child_pid) = self.child_pid else {
445 return;
446 };
447
448 if let Ok(mut guard) = child_pid.lock() {
449 *guard = None;
450 }
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use std::process::Command;
457 use std::time::Duration;
458
459 use tempfile::tempdir;
460
461 use super::*;
462 use crate::MockAgentBackend;
463 use crate::app_server::{AppServerTurnResponse, MockAppServerClient};
464
465 fn mock_shell_command(stdout: &str, stderr: &str, exit_code: i32) -> Command {
467 let mut command = Command::new("sh");
468 command.arg("-c").arg(
469 "printf '%s' \"$ONE_SHOT_STDOUT\"; printf '%s' \"$ONE_SHOT_STDERR\" >&2; exit \
470 \"$ONE_SHOT_EXIT\"",
471 );
472 command.env("ONE_SHOT_STDOUT", stdout);
473 command.env("ONE_SHOT_STDERR", stderr);
474 command.env("ONE_SHOT_EXIT", exit_code.to_string());
475 command.stdout(std::process::Stdio::piped());
476 command.stderr(std::process::Stdio::piped());
477
478 command
479 }
480
481 fn stdin_capture_shell_command(capture_path: &Path) -> Command {
483 let mut command = Command::new("sh");
484 command.arg("-c").arg(
485 "cat > \"$ONE_SHOT_CAPTURE_PATH\"; printf '%s' \
486 '{\"answer\":\"captured\",\"questions\":[],\"summary\":null}'",
487 );
488 command.env("ONE_SHOT_CAPTURE_PATH", capture_path);
489 command.stdout(std::process::Stdio::piped());
490 command.stderr(std::process::Stdio::piped());
491
492 command
493 }
494
495 #[tokio::test]
496 async fn test_submit_one_shot_with_backend_returns_protocol_response() {
498 let temp_directory = tempdir().expect("failed to create temp dir");
500 let mut backend = MockAgentBackend::new();
501 backend.expect_build_command().returning(|request| {
502 assert!(matches!(
503 request.request_kind,
504 AgentRequestKind::UtilityPrompt
505 ));
506 assert_eq!(request.prompt, "Generate title");
507
508 Ok(mock_shell_command(
509 r#"{"answer":"Generated title","questions":[],"summary":null}"#,
510 "",
511 0,
512 ))
513 });
514
515 let response = submit_one_shot_with_backend(
517 &backend,
518 OneShotRequest {
519 agent_kind: AgentKind::Claude,
520 child_pid: None,
521 folder: temp_directory.path(),
522 model: AgentModel::ClaudeSonnet5,
523 prompt: "Generate title",
524 request_kind: AgentRequestKind::UtilityPrompt,
525 reasoning_level: ReasoningLevel::default(),
526 },
527 )
528 .await
529 .expect("one-shot prompt should succeed");
530
531 assert_eq!(
533 response.response.answers(),
534 vec!["Generated title".to_string()]
535 );
536 }
537
538 #[tokio::test]
539 async fn test_submit_one_shot_with_backend_rejects_plain_text_utility_output() {
542 let temp_directory = tempdir().expect("failed to create temp dir");
544 let mut backend = MockAgentBackend::new();
545 backend
546 .expect_build_command()
547 .times(2)
548 .returning(|request| {
549 assert!(matches!(
550 request.request_kind,
551 AgentRequestKind::UtilityPrompt
552 ));
553
554 Ok(mock_shell_command("plain text", "", 0))
555 });
556
557 let error = submit_one_shot_with_backend(
559 &backend,
560 OneShotRequest {
561 agent_kind: AgentKind::Codex,
562 child_pid: None,
563 folder: temp_directory.path(),
564 model: AgentModel::Gpt55,
565 prompt: "Generate title",
566 request_kind: AgentRequestKind::UtilityPrompt,
567 reasoning_level: ReasoningLevel::default(),
568 },
569 )
570 .await
571 .expect_err("plain-text utility output should fail");
572
573 assert!(error.contains("did not match the required JSON schema"));
575 assert!(error.contains("debug_details:"));
576 assert!(error.contains("direct_json_error_location: line 1, column 1"));
577 assert!(error.contains("response:\nplain text"));
578 }
579
580 #[tokio::test]
581 async fn test_submit_one_shot_with_backend_rejects_wrapped_plain_text_utility_output() {
584 let temp_directory = tempdir().expect("failed to create temp dir");
586 let mut backend = MockAgentBackend::new();
587 backend
588 .expect_build_command()
589 .times(2)
590 .returning(|request| {
591 assert!(matches!(
592 request.request_kind,
593 AgentRequestKind::UtilityPrompt
594 ));
595
596 Ok(mock_shell_command(
597 r#"{"result":"plain text","usage":{"input_tokens":2,"output_tokens":1}}"#,
598 "",
599 0,
600 ))
601 });
602
603 let error = submit_one_shot_with_backend(
605 &backend,
606 OneShotRequest {
607 agent_kind: AgentKind::Claude,
608 child_pid: None,
609 folder: temp_directory.path(),
610 model: AgentModel::ClaudeSonnet5,
611 prompt: "Generate title",
612 request_kind: AgentRequestKind::UtilityPrompt,
613 reasoning_level: ReasoningLevel::default(),
614 },
615 )
616 .await
617 .expect_err("wrapped plain-text utility output should fail");
618
619 assert!(error.contains("did not match the required JSON schema"));
622 assert!(error.contains("direct_json_error:"));
623 assert!(error.contains("response:\nplain text"));
624 }
625
626 #[tokio::test]
627 async fn test_submit_one_shot_with_backend_recovers_wrapped_protocol_output() {
630 let temp_directory = tempdir().expect("failed to create temp dir");
632 let mut backend = MockAgentBackend::new();
633 backend
634 .expect_build_command()
635 .times(1)
636 .returning(|request| {
637 assert!(matches!(
638 request.request_kind,
639 AgentRequestKind::UtilityPrompt
640 ));
641 assert_eq!(request.prompt, "Generate title");
642
643 Ok(mock_shell_command(
644 concat!(
645 "Now I have full context.\n",
646 r#"{"answer":"Generated title","questions":[],"summary":null}"#
647 ),
648 "",
649 0,
650 ))
651 });
652
653 let response = submit_one_shot_with_backend(
655 &backend,
656 OneShotRequest {
657 agent_kind: AgentKind::Claude,
658 child_pid: None,
659 folder: temp_directory.path(),
660 model: AgentModel::ClaudeSonnet5,
661 prompt: "Generate title",
662 request_kind: AgentRequestKind::UtilityPrompt,
663 reasoning_level: ReasoningLevel::default(),
664 },
665 )
666 .await
667 .expect("wrapped protocol output should succeed");
668
669 assert_eq!(
671 response.response.answers(),
672 vec!["Generated title".to_string()]
673 );
674 }
675
676 #[tokio::test]
677 async fn test_submit_one_shot_with_backend_recovers_via_protocol_repair() {
680 let temp_directory = tempdir().expect("failed to create temp dir");
682 let call_counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
683 let mut backend = MockAgentBackend::new();
684 backend.expect_build_command().times(2).returning({
685 let counter = std::sync::Arc::clone(&call_counter);
686
687 move |_| {
688 let call_number = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
689
690 if call_number == 0 {
691 Ok(mock_shell_command("plain text", "", 0))
692 } else {
693 Ok(mock_shell_command(
694 r#"{"answer":"Repaired title","questions":[],"summary":null}"#,
695 "",
696 0,
697 ))
698 }
699 }
700 });
701
702 let response = submit_one_shot_with_backend(
704 &backend,
705 OneShotRequest {
706 agent_kind: AgentKind::Codex,
707 child_pid: None,
708 folder: temp_directory.path(),
709 model: AgentModel::Gpt55,
710 prompt: "Generate title",
711 request_kind: AgentRequestKind::UtilityPrompt,
712 reasoning_level: ReasoningLevel::default(),
713 },
714 )
715 .await
716 .expect("repair retry should succeed");
717
718 assert_eq!(
720 response.response.answers(),
721 vec!["Repaired title".to_string()]
722 );
723 }
724
725 #[tokio::test]
726 async fn test_submit_one_shot_with_backend_rejects_blank_utility_output() {
729 let temp_directory = tempdir().expect("failed to create temp dir");
731 let mut backend = MockAgentBackend::new();
732 backend.expect_build_command().returning(|request| {
733 assert!(matches!(
734 request.request_kind,
735 AgentRequestKind::UtilityPrompt
736 ));
737
738 Ok(mock_shell_command(" ", "", 0))
739 });
740
741 let error = submit_one_shot_with_backend(
743 &backend,
744 OneShotRequest {
745 agent_kind: AgentKind::Codex,
746 child_pid: None,
747 folder: temp_directory.path(),
748 model: AgentModel::Gpt55,
749 prompt: "Generate title",
750 request_kind: AgentRequestKind::UtilityPrompt,
751 reasoning_level: ReasoningLevel::default(),
752 },
753 )
754 .await
755 .expect_err("blank utility output should fail");
756
757 assert!(error.contains("did not match the required JSON schema"));
759 assert!(error.contains("trimmed_len: 0 chars"));
760 assert!(error.contains("response:\n"));
761 }
762
763 #[tokio::test]
764 async fn test_submit_one_shot_with_backend_writes_large_stdin_concurrently() {
767 let temp_directory = tempdir().expect("failed to create temp dir");
769 let large_prompt = "x".repeat(512 * 1024);
770 let mut backend = MockAgentBackend::new();
771 backend.expect_build_command().returning(|_| {
772 let mut command = Command::new("sh");
773 command.arg("-c").arg(
774 "printf 'warming up\\n' >&2; sleep 0.1; cat >/dev/null; printf '%s' \
775 '{\"answer\":\"done\",\"questions\":[],\"summary\":null}'",
776 );
777 command.stdout(std::process::Stdio::piped());
778 command.stderr(std::process::Stdio::piped());
779
780 Ok(command)
781 });
782
783 let response = tokio::time::timeout(
785 Duration::from_secs(5),
786 submit_one_shot_with_backend(
787 &backend,
788 OneShotRequest {
789 agent_kind: AgentKind::Claude,
790 child_pid: None,
791 folder: temp_directory.path(),
792 model: AgentModel::ClaudeSonnet5,
793 prompt: &large_prompt,
794 request_kind: AgentRequestKind::UtilityPrompt,
795 reasoning_level: ReasoningLevel::default(),
796 },
797 ),
798 )
799 .await
800 .expect("one-shot prompt should not deadlock")
801 .expect("one-shot prompt should succeed");
802
803 assert_eq!(response.response.answers(), vec!["done".to_string()]);
805 }
806
807 #[tokio::test]
808 async fn test_submit_one_shot_with_backend_writes_prompt_to_stdin() {
811 let temp_directory = tempdir().expect("failed to create temp dir");
813 let capture_path = temp_directory.path().join("stdin.txt");
814 let mut backend = MockAgentBackend::new();
815 backend.expect_build_command().returning({
816 let capture_path = capture_path.clone();
817
818 move |_| Ok(stdin_capture_shell_command(&capture_path))
819 });
820
821 let response = submit_one_shot_with_backend(
823 &backend,
824 OneShotRequest {
825 agent_kind: AgentKind::Claude,
826 child_pid: None,
827 folder: temp_directory.path(),
828 model: AgentModel::ClaudeSonnet5,
829 prompt: "Generate title",
830 request_kind: AgentRequestKind::UtilityPrompt,
831 reasoning_level: ReasoningLevel::default(),
832 },
833 )
834 .await
835 .expect("one-shot prompt should succeed");
836 let captured_prompt =
837 std::fs::read_to_string(&capture_path).expect("captured stdin payload should exist");
838
839 assert_eq!(response.response.answers(), vec!["captured".to_string()]);
841 assert!(captured_prompt.contains("Structured response protocol:"));
842 assert!(captured_prompt.contains("Generate title"));
843 }
844
845 #[tokio::test]
846 async fn test_submit_one_shot_with_backend_preserves_exit_error_after_broken_pipe() {
849 let temp_directory = tempdir().expect("failed to create temp dir");
851 let large_prompt = "x".repeat(512 * 1024);
852 let mut backend = MockAgentBackend::new();
853 backend.expect_build_command().returning(|_| {
854 let mut command = Command::new("sh");
855 command.arg("-c").arg("printf 'auth failed' >&2; exit 7");
856 command.stdout(std::process::Stdio::piped());
857 command.stderr(std::process::Stdio::piped());
858
859 Ok(command)
860 });
861
862 let error = submit_one_shot_with_backend(
864 &backend,
865 OneShotRequest {
866 agent_kind: AgentKind::Claude,
867 child_pid: None,
868 folder: temp_directory.path(),
869 model: AgentModel::ClaudeSonnet5,
870 prompt: &large_prompt,
871 request_kind: AgentRequestKind::UtilityPrompt,
872 reasoning_level: ReasoningLevel::default(),
873 },
874 )
875 .await
876 .expect_err("one-shot prompt should surface the child exit");
877
878 assert!(error.contains("exit code 7"), "error was: {error}");
880 assert!(error.contains("auth failed"), "error was: {error}");
881 assert!(
882 !error.contains("stdin payload"),
883 "stdin write error should not mask child failure: {error}"
884 );
885 }
886
887 #[tokio::test]
888 async fn test_submit_one_shot_with_backend_surfaces_claude_auth_guidance() {
891 let temp_directory = tempdir().expect("failed to create temp dir");
893 let mut backend = MockAgentBackend::new();
894 backend.expect_build_command().returning(|_| {
895 Ok(mock_shell_command(
896 r#"{"type":"error","error":{"type":"authentication_error","message":"OAuth token has expired. Please obtain a new token or refresh your existing token."}}"#,
897 "",
898 1,
899 ))
900 });
901
902 let error = submit_one_shot_with_backend(
904 &backend,
905 OneShotRequest {
906 agent_kind: AgentKind::Claude,
907 child_pid: None,
908 folder: temp_directory.path(),
909 model: AgentModel::ClaudeSonnet5,
910 prompt: "Generate title",
911 request_kind: AgentRequestKind::UtilityPrompt,
912 reasoning_level: ReasoningLevel::default(),
913 },
914 )
915 .await
916 .expect_err("expired Claude auth should fail");
917
918 assert!(
920 error.contains("One-shot agent command failed because Claude authentication expired")
921 );
922 assert!(error.contains("`claude auth login`"));
923 assert!(error.contains("`claude auth status`"));
924 }
925
926 #[tokio::test]
927 async fn test_submit_one_shot_with_app_server_client_returns_protocol_response() {
930 let temp_directory = tempdir().expect("failed to create temp dir");
932 let mut app_server_client = MockAppServerClient::new();
933 app_server_client
934 .expect_run_turn()
935 .times(1)
936 .returning(|request, _| {
937 assert_eq!(request.model, AgentModel::Gpt55.as_str());
938 assert!(matches!(
939 request.request_kind,
940 AgentRequestKind::UtilityPrompt
941 ));
942 assert_eq!(request.prompt.text, "Generate title");
943
944 Box::pin(async {
945 Ok(AppServerTurnResponse {
946 assistant_message:
947 r#"{"answer":"Generated title","questions":[],"summary":null}"#
948 .to_string(),
949 context_reset: false,
950 input_tokens: 11,
951 output_tokens: 7,
952 pid: Some(42),
953 provider_conversation_id: Some("thread-1".to_string()),
954 })
955 })
956 });
957 app_server_client
958 .expect_shutdown_session()
959 .times(1)
960 .returning(|_| Box::pin(async {}));
961
962 let response = submit_one_shot_with_app_server_client(
964 &app_server_client,
965 OneShotRequest {
966 agent_kind: AgentKind::Codex,
967 child_pid: None,
968 folder: temp_directory.path(),
969 model: AgentModel::Gpt55,
970 prompt: "Generate title",
971 request_kind: AgentRequestKind::UtilityPrompt,
972 reasoning_level: ReasoningLevel::default(),
973 },
974 )
975 .await
976 .expect("one-shot prompt should succeed");
977
978 assert_eq!(
980 response.response.answers(),
981 vec!["Generated title".to_string()]
982 );
983 assert_eq!(response.stats.input_tokens, 11);
984 assert_eq!(response.stats.output_tokens, 7);
985 }
986
987 #[tokio::test]
988 async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_utility_output() {
992 let temp_directory = tempdir().expect("failed to create temp dir");
994 let mut app_server_client = MockAppServerClient::new();
995 app_server_client
996 .expect_run_turn()
997 .times(2)
998 .returning(|request, _| {
999 assert_eq!(request.model, AgentModel::Gpt55.as_str());
1000
1001 Box::pin(async {
1002 Ok(AppServerTurnResponse {
1003 assistant_message: "plain text".to_string(),
1004 context_reset: false,
1005 input_tokens: 2,
1006 output_tokens: 1,
1007 pid: None,
1008 provider_conversation_id: None,
1009 })
1010 })
1011 });
1012 app_server_client
1013 .expect_shutdown_session()
1014 .times(1)
1015 .returning(|_| Box::pin(async {}));
1016
1017 let error = submit_one_shot_with_app_server_client(
1019 &app_server_client,
1020 OneShotRequest {
1021 agent_kind: AgentKind::Codex,
1022 child_pid: None,
1023 folder: temp_directory.path(),
1024 model: AgentModel::Gpt55,
1025 prompt: "Generate title",
1026 request_kind: AgentRequestKind::UtilityPrompt,
1027 reasoning_level: ReasoningLevel::default(),
1028 },
1029 )
1030 .await
1031 .expect_err("plain-text utility output should fail");
1032
1033 assert!(error.contains("did not match the required JSON schema"));
1035 assert!(error.contains("debug_details:"));
1036 assert!(error.contains("response:\nplain text"));
1037 }
1038
1039 #[tokio::test]
1040 async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_non_utility_output() {
1044 let temp_directory = tempdir().expect("failed to create temp dir");
1046 let mut app_server_client = MockAppServerClient::new();
1047 app_server_client
1048 .expect_run_turn()
1049 .times(2)
1050 .returning(|request, _| {
1051 assert!(matches!(
1052 request.request_kind,
1053 AgentRequestKind::SessionStart
1054 ));
1055
1056 Box::pin(async {
1057 Ok(AppServerTurnResponse {
1058 assistant_message: "plain text".to_string(),
1059 context_reset: false,
1060 input_tokens: 2,
1061 output_tokens: 1,
1062 pid: None,
1063 provider_conversation_id: None,
1064 })
1065 })
1066 });
1067 app_server_client
1068 .expect_shutdown_session()
1069 .times(1)
1070 .returning(|_| Box::pin(async {}));
1071
1072 let error = submit_one_shot_with_app_server_client(
1074 &app_server_client,
1075 OneShotRequest {
1076 agent_kind: AgentKind::Codex,
1077 child_pid: None,
1078 folder: temp_directory.path(),
1079 model: AgentModel::Gpt55,
1080 prompt: "Generate title",
1081 request_kind: AgentRequestKind::SessionStart,
1082 reasoning_level: ReasoningLevel::default(),
1083 },
1084 )
1085 .await
1086 .expect_err("invalid non-utility output should fail");
1087
1088 assert!(error.contains("did not match the required JSON schema"));
1090 assert!(error.contains("debug_details:"));
1091 assert!(error.contains("response:\nplain text"));
1092 }
1093}