use std::os::unix::process::ExitStatusExt as _;
use std::path::Path;
use std::sync::{Arc, Mutex};
use ag_protocol::{
AgentResponse, build_protocol_repair_prompt, format_protocol_parse_debug_details,
parse_agent_response_strict,
};
use super::backend::{AgentBackend, BuildCommandRequest};
use super::cli::{error, stdin};
use super::{
ParsedResponse, create_app_server_client, create_backend, parse_response, transport_mode,
};
use crate::app_server::{AppServerClient, AppServerTurnRequest};
use crate::channel::AgentRequestKind;
use crate::model::agent::{AgentKind, AgentModel, ReasoningLevel};
use crate::model::session::SessionStats;
#[derive(Clone, Debug)]
pub struct OneShotRequest<'a> {
pub agent_kind: AgentKind,
pub child_pid: Option<&'a Mutex<Option<u32>>>,
pub folder: &'a Path,
pub model: AgentModel,
pub prompt: &'a str,
pub request_kind: AgentRequestKind,
pub reasoning_level: ReasoningLevel,
}
#[derive(Clone, Debug, PartialEq)]
pub struct OneShotSubmission {
pub response: AgentResponse,
pub stats: SessionStats,
}
pub async fn submit_one_shot(request: OneShotRequest<'_>) -> Result<AgentResponse, String> {
let submission = submit_one_shot_with_stats(request).await?;
Ok(submission.response)
}
pub(crate) async fn submit_one_shot_with_stats(
request: OneShotRequest<'_>,
) -> Result<OneShotSubmission, String> {
submit_one_shot_with_stats_and_app_server_client(request, None).await
}
pub(crate) async fn submit_one_shot_with_stats_and_app_server_client(
request: OneShotRequest<'_>,
app_server_client_override: Option<Arc<dyn AppServerClient>>,
) -> Result<OneShotSubmission, String> {
let backend = create_backend(request.agent_kind);
if transport_mode(request.agent_kind).uses_app_server() {
let app_server_client =
create_app_server_client(request.agent_kind, app_server_client_override).ok_or_else(
|| {
format!(
"{} provider did not provide an app-server client",
request.agent_kind
)
},
)?;
return submit_one_shot_with_app_server_client(app_server_client.as_ref(), request).await;
}
submit_one_shot_with_backend(backend.as_ref(), request).await
}
pub async fn submit_one_shot_with_app_server_client(
app_server_client: &dyn AppServerClient,
request: OneShotRequest<'_>,
) -> Result<OneShotSubmission, String> {
clear_child_pid_slot(request.child_pid);
let session_id = format!("one-shot-{}", uuid::Uuid::new_v4());
let (stream_tx, _stream_rx) = tokio::sync::mpsc::unbounded_channel();
let turn_request = AppServerTurnRequest {
folder: request.folder.to_path_buf(),
live_transcript: None,
main_checkout_root: None,
model: request.model.provider_model_str().to_string(),
prompt: crate::model::turn_prompt::TurnPrompt::from_agent_data(request.prompt.to_string()),
request_kind: request.request_kind.clone(),
replay_transcript: None,
provider_conversation_id: None,
persisted_instruction_conversation_id: None,
reasoning_level: request.reasoning_level,
session_id: session_id.clone(),
};
let turn_result = app_server_client.run_turn(turn_request, stream_tx).await;
let child_pid = request.child_pid;
let turn_result = match turn_result {
Ok(result) => result,
Err(error) => {
app_server_client.shutdown_session(session_id).await;
clear_child_pid_slot(child_pid);
return Err(format!(
"Failed to execute one-shot app-server turn: {error}"
));
}
};
let parse_result = match parse_one_shot_response(&turn_result.assistant_message) {
Ok(response) => Ok((response, 0, 0)),
Err(parse_error) => {
attempt_one_shot_app_server_repair(
app_server_client,
&parse_error,
&turn_result.assistant_message,
request,
&session_id,
turn_result.provider_conversation_id.as_deref(),
)
.await
}
};
app_server_client.shutdown_session(session_id).await;
clear_child_pid_slot(child_pid);
let (response, repair_input_tokens, repair_output_tokens) = parse_result?;
Ok(OneShotSubmission {
response,
stats: SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens: turn_result.input_tokens + repair_input_tokens,
output_tokens: turn_result.output_tokens + repair_output_tokens,
},
})
}
pub async fn submit_one_shot_with_backend(
backend: &dyn AgentBackend,
request: OneShotRequest<'_>,
) -> Result<OneShotSubmission, String> {
let parsed_response =
execute_one_shot_command(backend, request.prompt, request.clone()).await?;
let (agent_response, repair_stats) = match parse_one_shot_response(&parsed_response.content) {
Ok(response) => (response, None),
Err(parse_error) => {
let repair_prompt =
build_protocol_repair_prompt(&parse_error, &parsed_response.content);
let repair_response = execute_one_shot_command(backend, &repair_prompt, request)
.await
.map_err(|error| format!("{parse_error}\nrepair transport failed: {error}"))?;
let response = parse_one_shot_response(&repair_response.content).map_err(|error| {
format!(
"{parse_error}\nrepair retry also failed: {error}\nrepair_response:\n{}",
repair_response.content
)
})?;
(response, Some(repair_response.stats))
}
};
let mut stats = parsed_response.stats;
if let Some(repair) = repair_stats {
stats.input_tokens += repair.input_tokens;
stats.output_tokens += repair.output_tokens;
}
Ok(OneShotSubmission {
response: agent_response,
stats,
})
}
fn parse_one_shot_response(content: &str) -> Result<AgentResponse, String> {
parse_agent_response_strict(content).map_err(|error| {
format!(
"One-shot agent output did not match the required JSON schema: \
{error}\ndebug_details:\n{}\nresponse:\n{content}",
format_protocol_parse_debug_details(content)
)
})
}
async fn attempt_one_shot_app_server_repair(
app_server_client: &dyn AppServerClient,
parse_error: &str,
malformed_response: &str,
request: OneShotRequest<'_>,
session_id: &str,
provider_conversation_id: Option<&str>,
) -> Result<(AgentResponse, u64, u64), String> {
let repair_prompt = build_protocol_repair_prompt(parse_error, malformed_response);
let (repair_stream_tx, _repair_stream_rx) = tokio::sync::mpsc::unbounded_channel();
let repair_turn_request = AppServerTurnRequest {
folder: request.folder.to_path_buf(),
live_transcript: None,
main_checkout_root: None,
model: request.model.provider_model_str().to_string(),
prompt: crate::model::turn_prompt::TurnPrompt::from_agent_data(repair_prompt),
request_kind: request.request_kind,
replay_transcript: None,
provider_conversation_id: provider_conversation_id.map(String::from),
persisted_instruction_conversation_id: None,
reasoning_level: request.reasoning_level,
session_id: session_id.to_string(),
};
let repair_result = app_server_client
.run_turn(repair_turn_request, repair_stream_tx)
.await
.map_err(|error| format!("{parse_error}\nrepair transport failed: {error}"))?;
let response = parse_one_shot_response(&repair_result.assistant_message).map_err(|error| {
format!(
"{parse_error}\nrepair retry also failed: {error}\nrepair_response:\n{}",
repair_result.assistant_message
)
})?;
Ok((
response,
repair_result.input_tokens,
repair_result.output_tokens,
))
}
async fn execute_one_shot_command(
backend: &dyn AgentBackend,
prompt: &str,
request: OneShotRequest<'_>,
) -> Result<ParsedResponse, String> {
let prompt_payload = crate::model::turn_prompt::TurnPrompt::from_agent_data(prompt.to_string());
let build_request = BuildCommandRequest {
attachments: &prompt_payload.attachments,
folder: request.folder,
main_checkout_root: None,
replay_transcript: None,
model: request.model.provider_model_str(),
prompt,
reasoning_level: request.reasoning_level,
request_kind: &request.request_kind,
};
let command = backend
.build_command(build_request)
.map_err(|error| format!("Failed to build one-shot agent command: {error}"))?;
let stdin_payload = super::build_command_stdin_payload(request.agent_kind, build_request)
.map_err(|error| format!("Failed to build one-shot agent stdin payload: {error}"))?;
let mut tokio_command = tokio::process::Command::from(command);
tokio_command
.stdin(if stdin_payload.is_some() {
std::process::Stdio::piped()
} else {
std::process::Stdio::null()
})
.kill_on_drop(true);
let mut pid_guard = ChildPidGuard::new(request.child_pid);
let mut child = tokio_command
.spawn()
.map_err(|error| format!("Failed to execute one-shot agent command: {error}"))?;
pid_guard.update_from_child(&child);
let stdin_write_task = stdin::spawn_optional_stdin_write(
child.stdin.take(),
stdin_payload,
"one-shot stdin pipe unavailable after spawn",
std::convert::identity,
);
let output = child
.wait_with_output()
.await
.map_err(|error| format!("Failed to execute one-shot agent command: {error}"))?;
stdin::await_optional_stdin_write(
stdin_write_task,
"One-shot stdin write task failed",
std::convert::identity,
)
.await?;
if output.status.signal().is_some() {
return Err("One-shot agent command was interrupted".to_string());
}
let stdout_text = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr_text = String::from_utf8_lossy(&output.stderr).into_owned();
if !output.status.success() {
return Err(format_one_shot_exit_error(
request.agent_kind,
output.status.code(),
&stdout_text,
&stderr_text,
));
}
let parsed_response = parse_response(request.agent_kind, &stdout_text, &stderr_text);
Ok(parsed_response)
}
fn format_one_shot_exit_error(
agent_kind: AgentKind,
exit_code: Option<i32>,
stdout: &str,
stderr: &str,
) -> String {
error::format_agent_cli_exit_error(
agent_kind,
"One-shot agent command",
exit_code,
stdout,
stderr,
)
}
fn clear_child_pid_slot(child_pid: Option<&Mutex<Option<u32>>>) {
let Some(child_pid) = child_pid else {
return;
};
if let Ok(mut guard) = child_pid.lock() {
*guard = None;
}
}
struct ChildPidGuard<'a> {
child_pid: Option<&'a Mutex<Option<u32>>>,
}
impl<'a> ChildPidGuard<'a> {
fn new(child_pid: Option<&'a Mutex<Option<u32>>>) -> Self {
Self { child_pid }
}
fn update_from_child(&mut self, child: &tokio::process::Child) {
let Some(pid) = child.id() else {
return;
};
let Some(child_pid) = self.child_pid else {
return;
};
if let Ok(mut guard) = child_pid.lock() {
*guard = Some(pid);
}
}
}
impl Drop for ChildPidGuard<'_> {
fn drop(&mut self) {
let Some(child_pid) = self.child_pid else {
return;
};
if let Ok(mut guard) = child_pid.lock() {
*guard = None;
}
}
}
#[cfg(test)]
mod tests {
use std::process::Command;
use std::time::Duration;
use tempfile::tempdir;
use super::*;
use crate::agent::tests::MockAgentBackend;
use crate::app_server::{AppServerTurnResponse, MockAppServerClient};
fn mock_shell_command(stdout: &str, stderr: &str, exit_code: i32) -> Command {
let mut command = Command::new("sh");
command.arg("-c").arg(
"printf '%s' \"$ONE_SHOT_STDOUT\"; printf '%s' \"$ONE_SHOT_STDERR\" >&2; exit \
\"$ONE_SHOT_EXIT\"",
);
command.env("ONE_SHOT_STDOUT", stdout);
command.env("ONE_SHOT_STDERR", stderr);
command.env("ONE_SHOT_EXIT", exit_code.to_string());
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
command
}
fn stdin_capture_shell_command(capture_path: &Path) -> Command {
let mut command = Command::new("sh");
command.arg("-c").arg(
"cat > \"$ONE_SHOT_CAPTURE_PATH\"; printf '%s' \
'{\"answer\":\"captured\",\"questions\":[],\"summary\":null}'",
);
command.env("ONE_SHOT_CAPTURE_PATH", capture_path);
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
command
}
#[tokio::test]
async fn test_submit_one_shot_with_backend_returns_protocol_response() {
let temp_directory = tempdir().expect("failed to create temp dir");
let mut backend = MockAgentBackend::new();
backend.expect_build_command().returning(|request| {
assert!(matches!(
request.request_kind,
AgentRequestKind::UtilityPrompt
));
assert_eq!(request.prompt, "Generate title");
Ok(mock_shell_command(
r#"{"answer":"Generated title","questions":[],"summary":null}"#,
"",
0,
))
});
let response = submit_one_shot_with_backend(
&backend,
OneShotRequest {
agent_kind: AgentKind::Claude,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::ClaudeSonnet5,
prompt: "Generate title",
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect("one-shot prompt should succeed");
assert_eq!(
response.response.answers(),
vec!["Generated title".to_string()]
);
}
#[tokio::test]
async fn test_submit_one_shot_with_backend_rejects_plain_text_utility_output() {
let temp_directory = tempdir().expect("failed to create temp dir");
let mut backend = MockAgentBackend::new();
backend
.expect_build_command()
.times(2)
.returning(|request| {
assert!(matches!(
request.request_kind,
AgentRequestKind::UtilityPrompt
));
Ok(mock_shell_command("plain text", "", 0))
});
let error = submit_one_shot_with_backend(
&backend,
OneShotRequest {
agent_kind: AgentKind::Codex,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::Gpt55,
prompt: "Generate title",
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect_err("plain-text utility output should fail");
assert!(error.contains("did not match the required JSON schema"));
assert!(error.contains("debug_details:"));
assert!(error.contains("direct_json_error_location: line 1, column 1"));
assert!(error.contains("response:\nplain text"));
}
#[tokio::test]
async fn test_submit_one_shot_with_backend_rejects_wrapped_plain_text_utility_output() {
let temp_directory = tempdir().expect("failed to create temp dir");
let mut backend = MockAgentBackend::new();
backend
.expect_build_command()
.times(2)
.returning(|request| {
assert!(matches!(
request.request_kind,
AgentRequestKind::UtilityPrompt
));
Ok(mock_shell_command(
r#"{"result":"plain text","usage":{"input_tokens":2,"output_tokens":1}}"#,
"",
0,
))
});
let error = submit_one_shot_with_backend(
&backend,
OneShotRequest {
agent_kind: AgentKind::Claude,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::ClaudeSonnet5,
prompt: "Generate title",
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect_err("wrapped plain-text utility output should fail");
assert!(error.contains("did not match the required JSON schema"));
assert!(error.contains("direct_json_error:"));
assert!(error.contains("response:\nplain text"));
}
#[tokio::test]
async fn test_submit_one_shot_with_backend_recovers_wrapped_protocol_output() {
let temp_directory = tempdir().expect("failed to create temp dir");
let mut backend = MockAgentBackend::new();
backend
.expect_build_command()
.times(1)
.returning(|request| {
assert!(matches!(
request.request_kind,
AgentRequestKind::UtilityPrompt
));
assert_eq!(request.prompt, "Generate title");
Ok(mock_shell_command(
concat!(
"Now I have full context.\n",
r#"{"answer":"Generated title","questions":[],"summary":null}"#
),
"",
0,
))
});
let response = submit_one_shot_with_backend(
&backend,
OneShotRequest {
agent_kind: AgentKind::Claude,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::ClaudeSonnet5,
prompt: "Generate title",
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect("wrapped protocol output should succeed");
assert_eq!(
response.response.answers(),
vec!["Generated title".to_string()]
);
}
#[tokio::test]
async fn test_submit_one_shot_with_backend_recovers_via_protocol_repair() {
let temp_directory = tempdir().expect("failed to create temp dir");
let call_counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut backend = MockAgentBackend::new();
backend.expect_build_command().times(2).returning({
let counter = std::sync::Arc::clone(&call_counter);
move |_| {
let call_number = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if call_number == 0 {
Ok(mock_shell_command("plain text", "", 0))
} else {
Ok(mock_shell_command(
r#"{"answer":"Repaired title","questions":[],"summary":null}"#,
"",
0,
))
}
}
});
let response = submit_one_shot_with_backend(
&backend,
OneShotRequest {
agent_kind: AgentKind::Codex,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::Gpt55,
prompt: "Generate title",
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect("repair retry should succeed");
assert_eq!(
response.response.answers(),
vec!["Repaired title".to_string()]
);
}
#[tokio::test]
async fn test_submit_one_shot_with_backend_rejects_blank_utility_output() {
let temp_directory = tempdir().expect("failed to create temp dir");
let mut backend = MockAgentBackend::new();
backend.expect_build_command().returning(|request| {
assert!(matches!(
request.request_kind,
AgentRequestKind::UtilityPrompt
));
Ok(mock_shell_command(" ", "", 0))
});
let error = submit_one_shot_with_backend(
&backend,
OneShotRequest {
agent_kind: AgentKind::Codex,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::Gpt55,
prompt: "Generate title",
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect_err("blank utility output should fail");
assert!(error.contains("did not match the required JSON schema"));
assert!(error.contains("trimmed_len: 0 chars"));
assert!(error.contains("response:\n"));
}
#[tokio::test]
async fn test_submit_one_shot_with_backend_writes_large_stdin_concurrently() {
let temp_directory = tempdir().expect("failed to create temp dir");
let large_prompt = "x".repeat(512 * 1024);
let mut backend = MockAgentBackend::new();
backend.expect_build_command().returning(|_| {
let mut command = Command::new("sh");
command.arg("-c").arg(
"printf 'warming up\\n' >&2; sleep 0.1; cat >/dev/null; printf '%s' \
'{\"answer\":\"done\",\"questions\":[],\"summary\":null}'",
);
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
Ok(command)
});
let response = tokio::time::timeout(
Duration::from_secs(5),
submit_one_shot_with_backend(
&backend,
OneShotRequest {
agent_kind: AgentKind::Claude,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::ClaudeSonnet5,
prompt: &large_prompt,
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
),
)
.await
.expect("one-shot prompt should not deadlock")
.expect("one-shot prompt should succeed");
assert_eq!(response.response.answers(), vec!["done".to_string()]);
}
#[tokio::test]
async fn test_submit_one_shot_with_backend_writes_prompt_to_stdin() {
let temp_directory = tempdir().expect("failed to create temp dir");
let capture_path = temp_directory.path().join("stdin.txt");
let mut backend = MockAgentBackend::new();
backend.expect_build_command().returning({
let capture_path = capture_path.clone();
move |_| Ok(stdin_capture_shell_command(&capture_path))
});
let response = submit_one_shot_with_backend(
&backend,
OneShotRequest {
agent_kind: AgentKind::Claude,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::ClaudeSonnet5,
prompt: "Generate title",
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect("one-shot prompt should succeed");
let captured_prompt =
std::fs::read_to_string(&capture_path).expect("captured stdin payload should exist");
assert_eq!(response.response.answers(), vec!["captured".to_string()]);
assert!(captured_prompt.contains("Structured response protocol:"));
assert!(captured_prompt.contains("Generate title"));
}
#[tokio::test]
async fn test_submit_one_shot_with_backend_preserves_exit_error_after_broken_pipe() {
let temp_directory = tempdir().expect("failed to create temp dir");
let large_prompt = "x".repeat(512 * 1024);
let mut backend = MockAgentBackend::new();
backend.expect_build_command().returning(|_| {
let mut command = Command::new("sh");
command.arg("-c").arg("printf 'auth failed' >&2; exit 7");
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
Ok(command)
});
let error = submit_one_shot_with_backend(
&backend,
OneShotRequest {
agent_kind: AgentKind::Claude,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::ClaudeSonnet5,
prompt: &large_prompt,
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect_err("one-shot prompt should surface the child exit");
assert!(error.contains("exit code 7"), "error was: {error}");
assert!(error.contains("auth failed"), "error was: {error}");
assert!(
!error.contains("stdin payload"),
"stdin write error should not mask child failure: {error}"
);
}
#[tokio::test]
async fn test_submit_one_shot_with_backend_surfaces_claude_auth_guidance() {
let temp_directory = tempdir().expect("failed to create temp dir");
let mut backend = MockAgentBackend::new();
backend.expect_build_command().returning(|_| {
Ok(mock_shell_command(
r#"{"type":"error","error":{"type":"authentication_error","message":"OAuth token has expired. Please obtain a new token or refresh your existing token."}}"#,
"",
1,
))
});
let error = submit_one_shot_with_backend(
&backend,
OneShotRequest {
agent_kind: AgentKind::Claude,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::ClaudeSonnet5,
prompt: "Generate title",
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect_err("expired Claude auth should fail");
assert!(
error.contains("One-shot agent command failed because Claude authentication expired")
);
assert!(error.contains("`claude auth login`"));
assert!(error.contains("`claude auth status`"));
}
#[tokio::test]
async fn test_submit_one_shot_with_app_server_client_returns_protocol_response() {
let temp_directory = tempdir().expect("failed to create temp dir");
let mut app_server_client = MockAppServerClient::new();
app_server_client
.expect_run_turn()
.times(1)
.returning(|request, _| {
assert_eq!(request.model, AgentModel::Gpt55.as_str());
assert!(matches!(
request.request_kind,
AgentRequestKind::UtilityPrompt
));
assert_eq!(request.prompt.text, "Generate title");
Box::pin(async {
Ok(AppServerTurnResponse {
assistant_message:
r#"{"answer":"Generated title","questions":[],"summary":null}"#
.to_string(),
context_reset: false,
input_tokens: 11,
output_tokens: 7,
pid: Some(42),
provider_conversation_id: Some("thread-1".to_string()),
})
})
});
app_server_client
.expect_shutdown_session()
.times(1)
.returning(|_| Box::pin(async {}));
let response = submit_one_shot_with_app_server_client(
&app_server_client,
OneShotRequest {
agent_kind: AgentKind::Codex,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::Gpt55,
prompt: "Generate title",
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect("one-shot prompt should succeed");
assert_eq!(
response.response.answers(),
vec!["Generated title".to_string()]
);
assert_eq!(response.stats.input_tokens, 11);
assert_eq!(response.stats.output_tokens, 7);
}
#[tokio::test]
async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_utility_output() {
let temp_directory = tempdir().expect("failed to create temp dir");
let mut app_server_client = MockAppServerClient::new();
app_server_client
.expect_run_turn()
.times(2)
.returning(|request, _| {
assert_eq!(request.model, AgentModel::Gpt55.as_str());
Box::pin(async {
Ok(AppServerTurnResponse {
assistant_message: "plain text".to_string(),
context_reset: false,
input_tokens: 2,
output_tokens: 1,
pid: None,
provider_conversation_id: None,
})
})
});
app_server_client
.expect_shutdown_session()
.times(1)
.returning(|_| Box::pin(async {}));
let error = submit_one_shot_with_app_server_client(
&app_server_client,
OneShotRequest {
agent_kind: AgentKind::Codex,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::Gpt55,
prompt: "Generate title",
request_kind: AgentRequestKind::UtilityPrompt,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect_err("plain-text utility output should fail");
assert!(error.contains("did not match the required JSON schema"));
assert!(error.contains("debug_details:"));
assert!(error.contains("response:\nplain text"));
}
#[tokio::test]
async fn test_submit_one_shot_with_app_server_client_rejects_plain_text_non_utility_output() {
let temp_directory = tempdir().expect("failed to create temp dir");
let mut app_server_client = MockAppServerClient::new();
app_server_client
.expect_run_turn()
.times(2)
.returning(|request, _| {
assert!(matches!(
request.request_kind,
AgentRequestKind::SessionStart
));
Box::pin(async {
Ok(AppServerTurnResponse {
assistant_message: "plain text".to_string(),
context_reset: false,
input_tokens: 2,
output_tokens: 1,
pid: None,
provider_conversation_id: None,
})
})
});
app_server_client
.expect_shutdown_session()
.times(1)
.returning(|_| Box::pin(async {}));
let error = submit_one_shot_with_app_server_client(
&app_server_client,
OneShotRequest {
agent_kind: AgentKind::Codex,
child_pid: None,
folder: temp_directory.path(),
model: AgentModel::Gpt55,
prompt: "Generate title",
request_kind: AgentRequestKind::SessionStart,
reasoning_level: ReasoningLevel::default(),
},
)
.await
.expect_err("invalid non-utility output should fail");
assert!(error.contains("did not match the required JSON schema"));
assert!(error.contains("debug_details:"));
assert!(error.contains("response:\nplain text"));
}
}