Support --output-schema for exec resume (#23123)

## Why

`codex exec resume` should have the same structured-output support as
top-level `codex exec`. Without `--output-schema`, multi-turn automation
has to choose between resumed session context and schema-validated JSON
output.

Fixes #22998.

## What changed

- Marked `--output-schema` as a global `codex exec` flag so it can be
passed after `resume`.
- Reused the existing output schema plumbing so resumed turns attach the
schema to the final response request while preserving session context.
This commit is contained in:
Eric Traut
2026-05-18 08:55:22 -07:00
committed by GitHub
Unverified
parent fce10e009d
commit af6ffb6ebb
4 changed files with 68 additions and 3 deletions
+7 -1
View File
@@ -2346,7 +2346,7 @@ mod tests {
}
#[test]
fn exec_resume_accepts_output_last_message_flag_after_subcommand() {
fn exec_resume_accepts_output_flags_after_subcommand() {
let cli = MultitoolCli::try_parse_from([
"codex",
"exec",
@@ -2354,6 +2354,8 @@ mod tests {
"session-123",
"-o",
"/tmp/resume-output.md",
"--output-schema",
"/tmp/schema.json",
"re-review",
])
.expect("parse should succeed");
@@ -2369,6 +2371,10 @@ mod tests {
exec.last_message_file,
Some(std::path::PathBuf::from("/tmp/resume-output.md"))
);
assert_eq!(
exec.output_schema,
Some(std::path::PathBuf::from("/tmp/schema.json"))
);
assert_eq!(args.session_id.as_deref(), Some("session-123"));
assert_eq!(args.prompt.as_deref(), Some("re-review"));
}
+1 -1
View File
@@ -50,7 +50,7 @@ pub struct Cli {
pub removed_full_auto: bool,
/// Path to a JSON Schema file describing the model's final response shape.
#[arg(long = "output-schema", value_name = "FILE")]
#[arg(long = "output-schema", value_name = "FILE", global = true)]
pub output_schema: Option<PathBuf>,
#[clap(skip)]
+4 -1
View File
@@ -36,7 +36,7 @@ fn resume_parses_prompt_after_global_flags() {
}
#[test]
fn resume_accepts_output_last_message_flag_after_subcommand() {
fn resume_accepts_output_flags_after_subcommand() {
const PROMPT: &str = "echo resume-with-output-file";
let cli = Cli::parse_from([
"codex-exec",
@@ -44,6 +44,8 @@ fn resume_accepts_output_last_message_flag_after_subcommand() {
"session-123",
"-o",
"/tmp/resume-output.md",
"--output-schema",
"/tmp/schema.json",
PROMPT,
]);
@@ -51,6 +53,7 @@ fn resume_accepts_output_last_message_flag_after_subcommand() {
cli.last_message_file,
Some(PathBuf::from("/tmp/resume-output.md"))
);
assert_eq!(cli.output_schema, Some(PathBuf::from("/tmp/schema.json")));
let Some(Command::Resume(args)) = cli.command else {
panic!("expected resume command");
};
+56
View File
@@ -375,6 +375,62 @@ async fn exec_resume_accepts_global_flags_after_subcommand() -> anyhow::Result<(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_resume_includes_output_schema_in_request() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let test = test_codex_exec();
let server = MockServer::start().await;
let response_mock = mount_exec_responses(&server, /*count*/ 2).await;
let schema_contents = serde_json::json!({
"type": "object",
"properties": {
"answer": { "type": "string" }
},
"required": ["answer"],
"additionalProperties": false
});
let schema_path = test.cwd_path().join("schema.json");
std::fs::write(&schema_path, serde_json::to_vec_pretty(&schema_contents)?)?;
test.cmd_with_server(&server)
.arg("--skip-git-repo-check")
.arg("echo seed-resume-session")
.assert()
.success();
test.cmd_with_server(&server)
.arg("--skip-git-repo-check")
.arg("resume")
.arg("--last")
.arg("--json")
.arg("--output-schema")
.arg(&schema_path)
.arg("echo resume-with-schema")
.assert()
.success();
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
let payload: Value = requests[1].body_json();
let text = payload.get("text").expect("request missing text field");
let format = text
.get("format")
.expect("request missing text.format field");
assert_eq!(
format,
&serde_json::json!({
"name": "codex_output_schema",
"type": "json_schema",
"strict": true,
"schema": schema_contents,
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));