merge(upstream): incorporate openai/codex@main into upstream-merge

- Adopt upstream changes in exec, mcp-server, and common by bucket.
- Preserve fork-specific core (codex.rs, browser/agent tools, UA/version).
- Add Prompt.output_schema; keep our Text controls; adjust tests.
- Fallback to Op::UserInput for UserTurn in exec and MCP.
- Keep ours for core codex files to preserve TUI/stream ordering.

verify.sh: pass; build-fast.sh: pass (no warnings).
This commit is contained in:
just-every-code
2025-09-23 21:30:54 +00:00
16 changed files with 1992 additions and 65 deletions
+1647 -61
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -39,6 +39,12 @@ pub(crate) async fn stream_chat_completions(
provider: &ModelProviderInfo,
debug_logger: &Arc<Mutex<DebugLogger>>,
) -> Result<ResponseStream> {
if prompt.output_schema.is_some() {
return Err(CodexErr::UnsupportedOperation(
"output_schema is not supported for Chat Completions API".to_string(),
));
}
// Build messages array
let mut messages = Vec::<serde_json::Value>::new();
+1 -1
View File
@@ -227,7 +227,7 @@ impl ModelClient {
// Create text parameter with verbosity and optional format.
// Historically not supported with ChatGPT auth, but allow it when a
// caller explicitly requests a `text.format` (side‑channel usage).
// caller explicitly requests a `text.format` (side-channel usage).
let want_format = prompt.text_format.as_ref();
let text = if auth_mode == Some(AuthMode::ChatGPT) && want_format.is_none() {
None
+56 -1
View File
@@ -12,6 +12,7 @@ use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use futures::Stream;
use serde::Serialize;
use serde_json::Value;
use std::borrow::Cow;
use std::ops::Deref;
use std::pin::Pin;
@@ -71,6 +72,8 @@ pub struct Prompt {
/// Optional per-request model family override matching `model_override`.
pub model_family_override: Option<ModelFamily>,
/// Optional the output schema for the model's response.
pub output_schema: Option<Value>,
}
impl Default for Prompt {
@@ -87,6 +90,7 @@ impl Default for Prompt {
text_format: None,
model_override: None,
model_family_override: None,
output_schema: None,
}
}
}
@@ -501,7 +505,7 @@ mod tests {
stream: true,
include: vec![],
prompt_cache_key: None,
text: Some(Text { verbosity: OpenAiTextVerbosity::Low }),
text: Some(Text { verbosity: OpenAiTextVerbosity::Low, format: None }),
};
let v = serde_json::to_value(&req).expect("json");
@@ -513,6 +517,57 @@ mod tests {
);
}
#[test]
fn serializes_text_schema_with_strict_format() {
let input: Vec<ResponseItem> = vec![];
let tools: Vec<serde_json::Value> = vec![];
let schema = serde_json::json!({
"type": "object",
"properties": {
"answer": {"type": "string"}
},
"required": ["answer"],
});
let req = ResponsesApiRequest {
model: "gpt-5",
instructions: "i",
input: &input,
tools: &tools,
tool_choice: "auto",
parallel_tool_calls: false,
reasoning: None,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,
text: Some(Text {
verbosity: OpenAiTextVerbosity::Medium,
format: Some(TextFormat {
r#type: "json_schema".to_string(),
name: Some("codex_output_schema".to_string()),
strict: Some(true),
schema: Some(schema.clone()),
}),
}),
};
let v = serde_json::to_value(&req).expect("json");
let text = v.get("text").expect("text field");
assert!(text.get("verbosity").is_none());
let format = text.get("format").expect("format field");
assert_eq!(
format.get("name"),
Some(&serde_json::Value::String("codex_output_schema".into()))
);
assert_eq!(
format.get("type"),
Some(&serde_json::Value::String("json_schema".into()))
);
assert_eq!(format.get("strict"), Some(&serde_json::Value::Bool(true)));
assert_eq!(format.get("schema"), Some(&schema));
}
#[test]
fn omits_text_when_not_set() {
let input: Vec<ResponseItem> = vec![];
+1
View File
@@ -3444,6 +3444,7 @@ async fn run_turn(
text_format: None,
model_override: None,
model_family_override: None,
output_schema: None,
};
// Start a new scratchpad for this HTTP attempt
+2
View File
@@ -127,6 +127,7 @@ pub(super) async fn perform_compaction(
text_format: None,
model_override: None,
model_family_override: None,
output_schema: None,
};
let max_retries = turn_context.client.get_provider().stream_max_retries();
@@ -233,6 +234,7 @@ async fn run_compact_task_inner_inline(
text_format: None,
model_override: None,
model_family_override: None,
output_schema: None,
};
let max_retries = turn_context.client.get_provider().stream_max_retries();
+3
View File
@@ -107,6 +107,9 @@ pub enum CodexErr {
#[error("codex-linux-sandbox was required but not provided")]
LandlockSandboxExecutableNotProvided,
#[error("unsupported operation: {0}")]
UnsupportedOperation(String),
// -----------------------------------------------------------------
// Automatic conversions for common external error types
// -----------------------------------------------------------------
+97
View File
@@ -0,0 +1,97 @@
#![cfg(not(target_os = "windows"))]
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
use core_test_support::non_sandbox_test;
use core_test_support::responses;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use responses::ev_assistant_message;
use responses::ev_completed;
use responses::sse;
use responses::start_mock_server;
const SCHEMA: &str = r#"
{
"type": "object",
"properties": {
"explanation": { "type": "string" },
"final_answer": { "type": "string" }
},
"required": ["explanation", "final_answer"],
"additionalProperties": false
}
"#;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn codex_returns_json_result() -> anyhow::Result<()> {
non_sandbox_test!(result);
let server = start_mock_server().await;
let sse1 = sse(vec![
ev_assistant_message(
"m2",
r#"{"explanation": "explanation", "final_answer": "final_answer"}"#,
),
ev_completed("r1"),
]);
let expected_schema: serde_json::Value = serde_json::from_str(SCHEMA)?;
let match_json_text_param = move |req: &wiremock::Request| {
let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap_or_default();
let Some(text) = body.get("text") else {
return false;
};
let Some(format) = text.get("format") else {
return false;
};
format.get("name") == Some(&serde_json::Value::String("codex_output_schema".into()))
&& format.get("type") == Some(&serde_json::Value::String("json_schema".into()))
&& format.get("strict") == Some(&serde_json::Value::Bool(true))
&& format.get("schema") == Some(&expected_schema)
};
responses::mount_sse_once(&server, match_json_text_param, sse1).await;
let TestCodex { codex, cwd, .. } = test_codex().build(&server).await?;
// 1) Normal user input – should hit server once.
codex
.submit(Op::UserTurn {
items: vec![InputItem::Text {
text: "hello world".into(),
}],
final_output_json_schema: Some(serde_json::from_str(SCHEMA)?),
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: "gpt-5".to_string(),
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
let message = wait_for_event(&codex, |ev| matches!(ev, EventMsg::AgentMessage(_))).await;
if let EventMsg::AgentMessage(message) = message {
let json: serde_json::Value = serde_json::from_str(&message.message)?;
assert_eq!(
json.get("explanation"),
Some(&serde_json::Value::String("explanation".into()))
);
assert_eq!(
json.get("final_answer"),
Some(&serde_json::Value::String("final_answer".into()))
);
} else {
anyhow::bail!("expected agent message event");
}
Ok(())
}
+1
View File
@@ -7,6 +7,7 @@ mod compact_resume_fork;
mod exec;
mod exec_stream_events;
mod fork_conversation;
mod json_result;
mod live_cli;
mod model_overrides;
mod prompt_caching;
@@ -656,6 +656,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
model: "o3".to_string(),
effort: Some(ReasoningEffort::High),
summary: ReasoningSummary::Detailed,
final_output_json_schema: None,
})
.await
.unwrap();
@@ -761,6 +762,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() {
model: default_model.clone(),
effort: default_effort,
summary: default_summary,
final_output_json_schema: None,
})
.await
.unwrap();
@@ -777,6 +779,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() {
model: default_model.clone(),
effort: default_effort,
summary: default_summary,
final_output_json_schema: None,
})
.await
.unwrap();
@@ -872,6 +875,7 @@ async fn send_user_turn_with_changes_sends_environment_context() {
model: default_model,
effort: default_effort,
summary: default_summary,
final_output_json_schema: None,
})
.await
.unwrap();
@@ -888,6 +892,7 @@ async fn send_user_turn_with_changes_sends_environment_context() {
model: "o3".to_string(),
effort: Some(ReasoningEffort::High),
summary: ReasoningSummary::Detailed,
final_output_json_schema: None,
})
.await
.unwrap();
+4
View File
@@ -61,6 +61,10 @@ pub struct Cli {
#[arg(long = "skip-git-repo-check", default_value_t = false)]
pub skip_git_repo_check: bool,
/// Path to a JSON Schema file describing the model's final response shape.
#[arg(long = "output-schema", value_name = "FILE")]
pub output_schema: Option<PathBuf>,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
+42 -2
View File
@@ -25,6 +25,7 @@ use codex_ollama::DEFAULT_OSS_MODEL;
use codex_protocol::config_types::SandboxMode;
use event_processor_with_human_output::EventProcessorWithHumanOutput;
use event_processor_with_json_output::EventProcessorWithJsonOutput;
use serde_json::Value;
use tracing::debug;
use tracing::error;
use tracing::info;
@@ -51,6 +52,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
json: json_mode,
sandbox_mode: sandbox_mode_cli_arg,
prompt,
output_schema: output_schema_path,
config_overrides,
..
} = cli;
@@ -97,6 +99,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
}
};
let _output_schema = load_output_schema(output_schema_path);
let (stdout_with_ansi, stderr_with_ansi) = match color {
cli::Color::Always => (true, true),
cli::Color::Never => (false, false),
@@ -198,7 +202,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
// is using.
event_processor.print_config_summary(&config, &prompt);
if !skip_git_repo_check && get_git_repo_root(&config.cwd.to_path_buf()).is_none() {
let default_cwd = config.cwd.to_path_buf();
let _default_approval_policy = config.approval_policy;
let _default_sandbox_policy = config.sandbox_policy.clone();
let _default_model = config.model.clone();
let _default_effort = config.model_reasoning_effort;
let _default_summary = config.model_reasoning_summary;
if !skip_git_repo_check && get_git_repo_root(&default_cwd).is_none() {
eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
std::process::exit(1);
}
@@ -300,7 +311,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
// Send the prompt.
let items: Vec<InputItem> = vec![InputItem::Text { text: prompt }];
let initial_prompt_task_id = conversation.submit(Op::UserInput { items }).await?;
// Fallback for older core protocol: send only user input items.
let initial_prompt_task_id = conversation
.submit(Op::UserInput { items })
.await?;
info!("Sent prompt with event ID: {initial_prompt_task_id}");
// Run the loop until the task is complete.
@@ -339,3 +353,29 @@ async fn resolve_resume_path(
Ok(None)
}
}
fn load_output_schema(path: Option<PathBuf>) -> Option<Value> {
let path = path?;
let schema_str = match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(err) => {
eprintln!(
"Failed to read output schema file {}: {err}",
path.display()
);
std::process::exit(1);
}
};
match serde_json::from_str::<Value>(&schema_str) {
Ok(value) => Some(value),
Err(err) => {
eprintln!(
"Output schema file {} is not valid JSON: {err}",
path.display()
);
std::process::exit(1);
}
}
}
+1
View File
@@ -1,5 +1,6 @@
// Aggregates all former standalone integration tests as modules.
mod apply_patch;
mod common;
mod output_schema;
mod resume;
mod sandbox;
@@ -0,0 +1,76 @@
#![cfg(not(target_os = "windows"))]
#![allow(clippy::expect_used, clippy::unwrap_used)]
use assert_cmd::prelude::*;
use core_test_support::responses;
use serde_json::Value;
use std::process::Command;
use tempfile::TempDir;
use wiremock::matchers::any;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_includes_output_schema_in_request() -> anyhow::Result<()> {
let home = TempDir::new()?;
let workspace = TempDir::new()?;
let schema_contents = serde_json::json!({
"type": "object",
"properties": {
"answer": { "type": "string" }
},
"required": ["answer"],
"additionalProperties": false
});
let schema_path = workspace.path().join("schema.json");
std::fs::write(&schema_path, serde_json::to_vec_pretty(&schema_contents)?)?;
let expected_schema: Value = schema_contents;
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
serde_json::json!({
"type": "response.created",
"response": {"id": "resp1"}
}),
responses::ev_assistant_message("m1", "fixture hello"),
responses::ev_completed("resp1"),
]);
responses::mount_sse_once(&server, any(), body).await;
Command::cargo_bin("codex-exec")?
.current_dir(workspace.path())
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
.env("OPENAI_BASE_URL", format!("{}/v1", server.uri()))
.arg("--skip-git-repo-check")
.arg("-C")
.arg(workspace.path())
.arg("--output-schema")
.arg(&schema_path)
.arg("-m")
.arg("gpt-5")
.arg("tell me a joke")
.assert()
.success();
let requests = server
.received_requests()
.await
.expect("failed to capture requests");
assert_eq!(requests.len(), 1, "expected exactly one request");
let payload: Value = serde_json::from_slice(&requests[0].body)?;
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": expected_schema,
})
);
Ok(())
}
@@ -235,6 +235,53 @@ impl CodexMessageProcessor {
.await;
}
#[allow(dead_code)]
async fn send_user_turn(&self, request_id: RequestId, params: SendUserTurnParams) {
let SendUserTurnParams {
conversation_id,
items,
cwd: _,
approval_policy: _,
sandbox_policy: _,
model: _,
effort: _,
summary: _,
} = params;
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {conversation_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
};
let mapped_items: Vec<CoreInputItem> = items
.into_iter()
.map(|item| match item {
WireInputItem::Text { text } => CoreInputItem::Text { text },
WireInputItem::Image { image_url } => CoreInputItem::Image { image_url },
WireInputItem::LocalImage { path } => CoreInputItem::LocalImage { path },
})
.collect();
// Core protocol compatibility: older cores do not support per-turn overrides.
// Submit only the user input items.
let _ = conversation
.submit(Op::UserInput { items: mapped_items })
.await;
self.outgoing
.send_response(request_id, SendUserTurnResponse {})
.await;
}
async fn interrupt_conversation(
&mut self,
request_id: RequestId,
+3
View File
@@ -24,6 +24,7 @@ use mcp_types::CallToolResult;
use mcp_types::Tool as McpTool;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use serde_with::serde_as;
use strum_macros::Display;
use ts_rs::TS;
@@ -94,6 +95,8 @@ pub enum Op {
/// Will only be honored if the model is configured to use reasoning.
summary: ReasoningSummaryConfig,
// The JSON schema to use for the final assistant message
final_output_json_schema: Option<Value>,
},
/// Override parts of the persistent turn context for subsequent turns.