diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index c8facffdb5..69a77a4461 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -102,6 +102,20 @@ macro_rules! for_each_schema_type { $macro!(codex_protocol::protocol::FileChange); $macro!(codex_protocol::parse_command::ParsedCommand); $macro!(codex_protocol::protocol::SandboxPolicy); + + // v2 protocol types (namespaced in JSON Schema under definitions.v2 and on disk under v2/) + $macro!(crate::protocol::v2::Account); + $macro!(crate::protocol::v2::LoginAccountParams); + $macro!(crate::protocol::v2::LoginAccountResponse); + $macro!(crate::protocol::v2::LogoutAccountResponse); + $macro!(crate::protocol::v2::GetAccountRateLimitsResponse); + $macro!(crate::protocol::v2::GetAccountResponse); + $macro!(crate::protocol::v2::ListModelsParams); + $macro!(crate::protocol::v2::ReasoningEffortOption); + $macro!(crate::protocol::v2::Model); + $macro!(crate::protocol::v2::ListModelsResponse); + $macro!(crate::protocol::v2::UploadFeedbackParams); + $macro!(crate::protocol::v2::UploadFeedbackResponse); }; } @@ -112,7 +126,9 @@ pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { } pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { + let v2_out_dir = out_dir.join("v2"); ensure_dir(out_dir)?; + ensure_dir(&v2_out_dir)?; ClientRequest::export_all_to(out_dir)?; export_client_responses(out_dir)?; @@ -123,12 +139,15 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { ServerNotification::export_all_to(out_dir)?; generate_index_ts(out_dir)?; + generate_index_ts(&v2_out_dir)?; - let ts_files = ts_files_in(out_dir)?; + // Ensure our header is present on all TS files (root + subdirs like v2/). + let ts_files = ts_files_in_recursive(out_dir)?; for file in &ts_files { prepend_header_if_missing(file)?; } + // Optionally run Prettier on all generated TS files. if let Some(prettier_bin) = prettier && !ts_files.is_empty() { @@ -147,13 +166,18 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { pub fn generate_json(out_dir: &Path) -> Result<()> { ensure_dir(out_dir)?; - let mut bundle: BTreeMap = BTreeMap::new(); + let mut bundle_root: BTreeMap = BTreeMap::new(); + let mut bundle_v2: BTreeMap = BTreeMap::new(); macro_rules! add_schema { ($ty:path) => {{ let name = type_basename(stringify!($ty)); - let schema = write_json_schema_with_return::<$ty>(out_dir, &name)?; - bundle.insert(name, schema); + let (schema, is_v2) = write_json_schema_with_return::<$ty>(out_dir, &name)?; + if is_v2 { + bundle_v2.insert(name, schema); + } else { + bundle_root.insert(name, schema); + } }}; } @@ -162,7 +186,26 @@ pub fn generate_json(out_dir: &Path) -> Result<()> { export_client_response_schemas(out_dir)?; export_server_response_schemas(out_dir)?; - let mut definitions = Map::new(); + let mut definitions_root = Map::new(); + let mut definitions_v2 = Map::new(); + + // Collect the set of ALL v2 definition names so we can selectively rewrite + // $refs that point at these names (and only these names). This must include + // hoisted nested definitions that schemars emits under `definitions` for + // v2 types (e.g., AccountApiKey, enum variant structs, etc.), not just the + // top-level type names we bundle. + let mut v2_names: HashSet = bundle_v2.keys().cloned().collect(); + for (_name, schema) in &bundle_v2 { + // Serialize the schema to peek at its hoisted `definitions` keys. + if let Ok(Value::Object(obj)) = serde_json::to_value(schema) { + if let Some(Value::Object(defs)) = obj.get("definitions") { + v2_names.extend(defs.keys().cloned()); + } + if let Some(Value::Object(defs)) = obj.get("$defs") { + v2_names.extend(defs.keys().cloned()); + } + } + } const SPECIAL_DEFINITIONS: &[&str] = &[ "ClientNotification", @@ -176,24 +219,60 @@ pub fn generate_json(out_dir: &Path) -> Result<()> { "ServerRequest", ]; - for (name, schema) in bundle { - let mut schema_value = serde_json::to_value(schema)?; - annotate_schema(&mut schema_value, Some(name.as_str())); + // Helper to process a bundle map into a definitions object, optionally rewriting + // refs to v2 but only for known v2 type names. When `drop_v2_defs` is true, any + // hoisted definitions whose name is a v2 type will be skipped to avoid + // duplicating them at the root while still preserving references by rewriting. + fn append_bundle( + src: BTreeMap, + out_defs: &mut Map, + v2_names: &HashSet, + rewrite_v2_refs: bool, + drop_v2_defs: bool, + ) -> Result<()> { + for (name, schema) in src { + let mut schema_value = serde_json::to_value(schema)?; + annotate_schema(&mut schema_value, Some(name.as_str())); + if rewrite_v2_refs { + rewrite_json_refs_to_v2_in_set(&mut schema_value, v2_names); + } - if let Value::Object(ref mut obj) = schema_value - && let Some(defs) = obj.remove("definitions") - && let Value::Object(defs_obj) = defs - { - for (def_name, mut def_schema) in defs_obj { - if !SPECIAL_DEFINITIONS.contains(&def_name.as_str()) { - annotate_schema(&mut def_schema, Some(def_name.as_str())); - definitions.insert(def_name, def_schema); + if let Value::Object(ref mut obj) = schema_value + && let Some(defs) = obj.remove("definitions") + && let Value::Object(defs_obj) = defs + { + for (def_name, mut def_schema) in defs_obj { + if !SPECIAL_DEFINITIONS.contains(&def_name.as_str()) { + annotate_schema(&mut def_schema, Some(def_name.as_str())); + if rewrite_v2_refs { + rewrite_json_refs_to_v2_in_set(&mut def_schema, v2_names); + } + if !(drop_v2_defs && v2_names.contains(&def_name)) { + out_defs.insert(def_name, def_schema); + } + } } } + out_defs.insert(name, schema_value); } - definitions.insert(name, schema_value); + Ok(()) } + append_bundle( + bundle_root, + &mut definitions_root, + &v2_names, + true, // rewrite v2 type refs inside root bundle + true, // and drop v2 definitions from root to avoid duplication + )?; + append_bundle( + bundle_v2, + &mut definitions_v2, + &v2_names, + true, // rewrite v2 type refs inside v2 bundle as well (for intra-v2 refs) + false, // keep v2 definitions under definitions.v2 + )?; + let mut root = Map::new(); root.insert( "$schema".to_string(), @@ -204,7 +283,11 @@ pub fn generate_json(out_dir: &Path) -> Result<()> { Value::String("CodexAppServerProtocol".into()), ); root.insert("type".to_string(), Value::String("object".into())); - root.insert("definitions".to_string(), Value::Object(definitions)); + // Compose root definitions with a nested v2 namespace. + if !definitions_v2.is_empty() { + definitions_root.insert("v2".to_string(), Value::Object(definitions_v2)); + } + root.insert("definitions".to_string(), Value::Object(definitions_root)); write_pretty_json( out_dir.join("codex_app_server_protocol.schemas.json"), @@ -214,18 +297,27 @@ pub fn generate_json(out_dir: &Path) -> Result<()> { Ok(()) } -fn write_json_schema_with_return(out_dir: &Path, name: &str) -> Result +fn write_json_schema_with_return(out_dir: &Path, name: &str) -> Result<(RootSchema, bool)> where T: JsonSchema, { - let file_stem = name.trim(); + let file_stem = type_basename(name); let schema = schema_for!(T); let mut schema_value = serde_json::to_value(schema)?; - annotate_schema(&mut schema_value, Some(file_stem)); - write_pretty_json(out_dir.join(format!("{file_stem}.json")), &schema_value) + annotate_schema(&mut schema_value, Some(&file_stem)); + // Decide output dir based on the Rust type path (v2 types under v2/). + let ty_name = std::any::type_name::(); + let is_v2 = ty_name.contains("::protocol::v2::"); + let target_dir = if is_v2 { + out_dir.join("v2") + } else { + out_dir.to_path_buf() + }; + ensure_dir(&target_dir)?; + write_pretty_json(target_dir.join(format!("{file_stem}.json")), &schema_value) .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?; let annotated_schema = serde_json::from_value(schema_value)?; - Ok(annotated_schema) + Ok((annotated_schema, is_v2)) } pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result<()> @@ -322,6 +414,40 @@ fn annotate_schema(value: &mut Value, base: Option<&str>) { } } +// Rewrite internal $ref targets of the form "#/definitions/" to +// "#/definitions/v2/". Skip special top-level definitions which are +// intentionally kept at the root. +// NOTE: legacy helper removed in favor of the selective variant below. + +// Like `rewrite_json_refs_to_v2`, but only rewrites references whose target name +// is present in `v2_names`. This avoids accidentally rewriting references to +// root-only definitions such as RequestId. +fn rewrite_json_refs_to_v2_in_set(value: &mut Value, v2_names: &HashSet) { + match value { + Value::Object(obj) => { + if let Some(Value::String(r)) = obj.get_mut("$ref") { + const PREFIX: &str = "#/definitions/"; + if r.starts_with("#/definitions/v2/") { + // already namespaced + } else if let Some(rest) = r.strip_prefix(PREFIX) + && v2_names.contains(rest) + { + *r = format!("#/definitions/v2/{rest}"); + } + } + for v in obj.values_mut() { + rewrite_json_refs_to_v2_in_set(v, v2_names); + } + } + Value::Array(arr) => { + for v in arr { + rewrite_json_refs_to_v2_in_set(v, v2_names); + } + } + _ => {} + } +} + fn annotate_object(map: &mut Map, base: Option<&str>) { let owner = map.get("title").and_then(Value::as_str).map(str::to_owned); if let Some(owner) = owner.as_deref() @@ -504,6 +630,26 @@ fn ts_files_in(dir: &Path) -> Result> { Ok(files) } +fn ts_files_in_recursive(dir: &Path) -> Result> { + let mut files = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + for entry in + fs::read_dir(&d).with_context(|| format!("Failed to read dir {}", d.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.is_file() && path.extension() == Some(OsStr::new("ts")) { + files.push(path); + } + } + } + files.sort(); + Ok(files) +} + fn generate_index_ts(out_dir: &Path) -> Result { let mut entries: Vec = Vec::new(); let mut stems: Vec = ts_files_in(out_dir)? @@ -520,6 +666,14 @@ fn generate_index_ts(out_dir: &Path) -> Result { entries.push(format!("export type {{ {name} }} from \"./{name}\";\n")); } + // If this is the root out_dir and a ./v2 folder exists with TS files, + // expose it as a namespace to avoid symbol collisions at the root. + let v2_dir = out_dir.join("v2"); + let has_v2_ts = ts_files_in(&v2_dir).map(|v| !v.is_empty()).unwrap_or(false); + if has_v2_ts { + entries.push("export * as v2 from \"./v2\";\n".to_string()); + } + let mut content = String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::()); content.push_str(HEADER); @@ -546,6 +700,7 @@ mod tests { #[test] fn generated_ts_has_no_optional_nullable_fields() -> Result<()> { + // Assert that there are no types of the form "?: T | null" in the generated TS files. let output_dir = std::env::temp_dir().join(format!("codex_ts_types_{}", Uuid::now_v7())); fs::create_dir(&output_dir)?; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ccd89e6a1f..fa082fa91c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -11,6 +11,7 @@ use uuid::Uuid; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] +#[ts(export_to = "v2/")] pub enum Account { #[serde(rename = "apiKey", rename_all = "camelCase")] #[ts(rename = "apiKey", rename_all = "camelCase")] @@ -27,6 +28,7 @@ pub enum Account { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type")] #[ts(tag = "type")] +#[ts(export_to = "v2/")] pub enum LoginAccountParams { #[serde(rename = "apiKey")] #[ts(rename = "apiKey")] @@ -42,6 +44,7 @@ pub enum LoginAccountParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct LoginAccountResponse { /// Only set if the login method is ChatGPT. #[schemars(with = "String")] @@ -54,22 +57,26 @@ pub struct LoginAccountResponse { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct LogoutAccountResponse {} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct GetAccountRateLimitsResponse { pub rate_limits: RateLimitSnapshot, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct GetAccountResponse { pub account: Account, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct ListModelsParams { /// Optional page size; defaults to a reasonable server-side value. pub page_size: Option, @@ -79,6 +86,7 @@ pub struct ListModelsParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct Model { pub id: String, pub model: String, @@ -92,6 +100,7 @@ pub struct Model { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct ReasoningEffortOption { pub reasoning_effort: ReasoningEffort, pub description: String, @@ -99,6 +108,7 @@ pub struct ReasoningEffortOption { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct ListModelsResponse { pub items: Vec, /// Opaque cursor to pass to the next call to continue after the last item. @@ -108,6 +118,7 @@ pub struct ListModelsResponse { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct UploadFeedbackParams { pub classification: String, pub reason: Option, @@ -117,6 +128,7 @@ pub struct UploadFeedbackParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct UploadFeedbackResponse { pub thread_id: String, }