feat: experimental flags (#10231)

## Problem being solved
- We need a single, reliable way to mark app-server API surface as
experimental so that:
  1. the runtime can reject experimental usage unless the client opts in
2. generated TS/JSON schemas can exclude experimental methods/fields for
stable clients.

Right now that’s easy to drift or miss when done ad-hoc.

## How to declare experimental methods and fields
- **Experimental method**: add `#[experimental("method/name")]` to the
`ClientRequest` variant in `client_request_definitions!`.
- **Experimental field**: on the params struct, derive `ExperimentalApi`
and annotate the field with `#[experimental("method/name.field")]` + set
`inspect_params: true` for the method variant so
`ClientRequest::experimental_reason()` inspects params for experimental
fields.

## How the macro solves it
- The new derive macro lives in
`codex-rs/codex-experimental-api-macros/src/lib.rs` and is used via
`#[derive(ExperimentalApi)]` plus `#[experimental("reason")]`
attributes.
- **Structs**:
- Generates `ExperimentalApi::experimental_reason(&self)` that checks
only annotated fields.
  - The “presence” check is type-aware:
    - `Option<T>`: `is_some_and(...)` recursively checks inner.
    - `Vec`/`HashMap`/`BTreeMap`: must be non-empty.
    - `bool`: must be `true`.
    - Other types: considered present (returns `true`).
- Registers each experimental field in an `inventory` with `(type_name,
serialized field name, reason)` and exposes `EXPERIMENTAL_FIELDS` for
that type. Field names are converted from `snake_case` to `camelCase`
for schema/TS filtering.
- **Enums**:
- Generates an exhaustive `match` returning `Some(reason)` for annotated
variants and `None` otherwise (no wildcard arm).
- **Wiring**:
- Runtime gating uses `ExperimentalApi::experimental_reason()` in
`codex-rs/app-server/src/message_processor.rs` to reject requests unless
`InitializeParams.capabilities.experimental_api == true`.
- Schema/TS export filters use the inventory list and
`EXPERIMENTAL_CLIENT_METHODS` from `client_request_definitions!` to
strip experimental methods/fields when `experimental_api` is false.
This commit is contained in:
jif-oai
2026-02-02 12:06:50 +01:00
committed by GitHub
parent 9513f18bfe
commit 3cc9122ee2
37 changed files with 2682 additions and 1076 deletions

View File

@@ -2,6 +2,7 @@ use crate::ClientNotification;
use crate::ClientRequest;
use crate::ServerNotification;
use crate::ServerRequest;
use crate::experimental_api::experimental_fields;
use crate::export_client_notification_schemas;
use crate::export_client_param_schemas;
use crate::export_client_response_schemas;
@@ -10,6 +11,9 @@ use crate::export_server_notification_schemas;
use crate::export_server_param_schemas;
use crate::export_server_response_schemas;
use crate::export_server_responses;
use crate::protocol::common::EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES;
use crate::protocol::common::EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES;
use crate::protocol::common::EXPERIMENTAL_CLIENT_METHODS;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
@@ -67,6 +71,7 @@ pub struct GenerateTsOptions {
pub generate_indices: bool,
pub ensure_headers: bool,
pub run_prettier: bool,
pub experimental_api: bool,
}
impl Default for GenerateTsOptions {
@@ -75,6 +80,7 @@ impl Default for GenerateTsOptions {
generate_indices: true,
ensure_headers: true,
run_prettier: true,
experimental_api: false,
}
}
}
@@ -100,6 +106,10 @@ pub fn generate_ts_with_options(
export_server_responses(out_dir)?;
ServerNotification::export_all_to(out_dir)?;
if !options.experimental_api {
filter_experimental_ts(out_dir)?;
}
if options.generate_indices {
generate_index_ts(out_dir)?;
generate_index_ts(&v2_out_dir)?;
@@ -140,8 +150,12 @@ pub fn generate_ts_with_options(
}
pub fn generate_json(out_dir: &Path) -> Result<()> {
generate_json_with_experimental(out_dir, false)
}
pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) -> Result<()> {
ensure_dir(out_dir)?;
let envelope_emitters: &[JsonSchemaEmitter] = &[
let envelope_emitters: Vec<JsonSchemaEmitter> = vec![
|d| write_json_schema_with_return::<crate::RequestId>(d, "RequestId"),
|d| write_json_schema_with_return::<crate::JSONRPCMessage>(d, "JSONRPCMessage"),
|d| write_json_schema_with_return::<crate::JSONRPCRequest>(d, "JSONRPCRequest"),
@@ -157,7 +171,7 @@ pub fn generate_json(out_dir: &Path) -> Result<()> {
];
let mut schemas: Vec<GeneratedSchema> = Vec::new();
for emit in envelope_emitters {
for emit in &envelope_emitters {
schemas.push(emit(out_dir)?);
}
@@ -168,15 +182,654 @@ pub fn generate_json(out_dir: &Path) -> Result<()> {
schemas.extend(export_client_notification_schemas(out_dir)?);
schemas.extend(export_server_notification_schemas(out_dir)?);
let bundle = build_schema_bundle(schemas)?;
let mut bundle = build_schema_bundle(schemas)?;
if !experimental_api {
filter_experimental_schema(&mut bundle)?;
}
write_pretty_json(
out_dir.join("codex_app_server_protocol.schemas.json"),
&bundle,
)?;
if !experimental_api {
filter_experimental_json_files(out_dir)?;
}
Ok(())
}
fn filter_experimental_ts(out_dir: &Path) -> Result<()> {
let registered_fields = experimental_fields();
let experimental_method_types = experimental_method_types();
// Most generated TS files are filtered by schema processing, but
// `ClientRequest.ts` and any type with `#[experimental(...)]` fields need
// direct post-processing because they encode method/field information in
// file-local unions/interfaces.
filter_client_request_ts(out_dir, EXPERIMENTAL_CLIENT_METHODS)?;
filter_experimental_type_fields_ts(out_dir, &registered_fields)?;
remove_generated_type_files(out_dir, &experimental_method_types, "ts")?;
Ok(())
}
/// Removes union arms from `ClientRequest.ts` for methods marked experimental.
fn filter_client_request_ts(out_dir: &Path, experimental_methods: &[&str]) -> Result<()> {
let path = out_dir.join("ClientRequest.ts");
if !path.exists() {
return Ok(());
}
let mut content =
fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
let Some((prefix, body, suffix)) = split_type_alias(&content) else {
return Ok(());
};
let experimental_methods: HashSet<&str> = experimental_methods
.iter()
.copied()
.filter(|method| !method.is_empty())
.collect();
let arms = split_top_level(&body, '|');
let filtered_arms: Vec<String> = arms
.into_iter()
.filter(|arm| {
extract_method_from_arm(arm)
.is_none_or(|method| !experimental_methods.contains(method.as_str()))
})
.collect();
let new_body = filtered_arms.join(" | ");
content = format!("{prefix}{new_body}{suffix}");
content = prune_unused_type_imports(content, &new_body);
fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
/// Removes experimental properties from generated TypeScript type files.
fn filter_experimental_type_fields_ts(
out_dir: &Path,
experimental_fields: &[&'static crate::experimental_api::ExperimentalField],
) -> Result<()> {
let mut fields_by_type_name: HashMap<String, HashSet<String>> = HashMap::new();
for field in experimental_fields {
fields_by_type_name
.entry(field.type_name.to_string())
.or_default()
.insert(field.field_name.to_string());
}
if fields_by_type_name.is_empty() {
return Ok(());
}
for path in ts_files_in_recursive(out_dir)? {
let Some(type_name) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
let Some(experimental_field_names) = fields_by_type_name.get(type_name) else {
continue;
};
filter_experimental_fields_in_ts_file(&path, experimental_field_names)?;
}
Ok(())
}
fn filter_experimental_fields_in_ts_file(
path: &Path,
experimental_field_names: &HashSet<String>,
) -> Result<()> {
let mut content =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let Some((open_brace, close_brace)) = type_body_brace_span(&content) else {
return Ok(());
};
let inner = &content[open_brace + 1..close_brace];
let fields = split_top_level_multi(inner, &[',', ';']);
let filtered_fields: Vec<String> = fields
.into_iter()
.filter(|field| {
let field = strip_leading_block_comments(field);
parse_property_name(field)
.is_none_or(|name| !experimental_field_names.contains(name.as_str()))
})
.collect();
let new_inner = filtered_fields.join(", ");
let prefix = &content[..open_brace + 1];
let suffix = &content[close_brace..];
content = format!("{prefix}{new_inner}{suffix}");
content = prune_unused_type_imports(content, &new_inner);
fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
fn filter_experimental_schema(bundle: &mut Value) -> Result<()> {
let registered_fields = experimental_fields();
filter_experimental_fields_in_root(bundle, &registered_fields);
filter_experimental_fields_in_definitions(bundle, &registered_fields);
prune_experimental_methods(bundle, EXPERIMENTAL_CLIENT_METHODS);
remove_experimental_method_type_definitions(bundle);
Ok(())
}
fn filter_experimental_fields_in_root(
schema: &mut Value,
experimental_fields: &[&'static crate::experimental_api::ExperimentalField],
) {
let Some(title) = schema.get("title").and_then(Value::as_str) else {
return;
};
let title = title.to_string();
for field in experimental_fields {
if title != field.type_name {
continue;
}
remove_property_from_schema(schema, field.field_name);
}
}
fn filter_experimental_fields_in_definitions(
bundle: &mut Value,
experimental_fields: &[&'static crate::experimental_api::ExperimentalField],
) {
let Some(definitions) = bundle.get_mut("definitions").and_then(Value::as_object_mut) else {
return;
};
filter_experimental_fields_in_definitions_map(definitions, experimental_fields);
}
fn filter_experimental_fields_in_definitions_map(
definitions: &mut Map<String, Value>,
experimental_fields: &[&'static crate::experimental_api::ExperimentalField],
) {
for (def_name, def_schema) in definitions.iter_mut() {
if is_namespace_map(def_schema) {
if let Some(namespace_defs) = def_schema.as_object_mut() {
filter_experimental_fields_in_definitions_map(namespace_defs, experimental_fields);
}
continue;
}
for field in experimental_fields {
if !definition_matches_type(def_name, field.type_name) {
continue;
}
remove_property_from_schema(def_schema, field.field_name);
}
}
}
fn is_namespace_map(value: &Value) -> bool {
let Value::Object(map) = value else {
return false;
};
if map.keys().any(|key| key.starts_with('$')) {
return false;
}
let looks_like_schema = map.contains_key("type")
|| map.contains_key("properties")
|| map.contains_key("anyOf")
|| map.contains_key("oneOf")
|| map.contains_key("allOf");
!looks_like_schema && map.values().all(Value::is_object)
}
fn definition_matches_type(def_name: &str, type_name: &str) -> bool {
def_name == type_name || def_name.ends_with(&format!("::{type_name}"))
}
fn remove_property_from_schema(schema: &mut Value, field_name: &str) {
if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
properties.remove(field_name);
}
if let Some(required) = schema.get_mut("required").and_then(Value::as_array_mut) {
required.retain(|entry| entry.as_str() != Some(field_name));
}
if let Some(inner_schema) = schema.get_mut("schema") {
remove_property_from_schema(inner_schema, field_name);
}
}
fn prune_experimental_methods(bundle: &mut Value, experimental_methods: &[&str]) {
let experimental_methods: HashSet<&str> = experimental_methods
.iter()
.copied()
.filter(|method| !method.is_empty())
.collect();
prune_experimental_methods_inner(bundle, &experimental_methods);
}
fn prune_experimental_methods_inner(value: &mut Value, experimental_methods: &HashSet<&str>) {
match value {
Value::Array(items) => {
items.retain(|item| !is_experimental_method_variant(item, experimental_methods));
for item in items {
prune_experimental_methods_inner(item, experimental_methods);
}
}
Value::Object(map) => {
for entry in map.values_mut() {
prune_experimental_methods_inner(entry, experimental_methods);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
fn is_experimental_method_variant(value: &Value, experimental_methods: &HashSet<&str>) -> bool {
let Value::Object(map) = value else {
return false;
};
let Some(properties) = map.get("properties").and_then(Value::as_object) else {
return false;
};
let Some(method_schema) = properties.get("method").and_then(Value::as_object) else {
return false;
};
if let Some(method) = method_schema.get("const").and_then(Value::as_str) {
return experimental_methods.contains(method);
}
if let Some(values) = method_schema.get("enum").and_then(Value::as_array)
&& values.len() == 1
&& let Some(method) = values[0].as_str()
{
return experimental_methods.contains(method);
}
false
}
fn filter_experimental_json_files(out_dir: &Path) -> Result<()> {
for path in json_files_in_recursive(out_dir)? {
let mut value = read_json_value(&path)?;
filter_experimental_schema(&mut value)?;
write_pretty_json(path, &value)?;
}
let experimental_method_types = experimental_method_types();
remove_generated_type_files(out_dir, &experimental_method_types, "json")?;
Ok(())
}
fn experimental_method_types() -> HashSet<String> {
let mut type_names = HashSet::new();
collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES, &mut type_names);
collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES, &mut type_names);
type_names
}
fn collect_experimental_type_names(entries: &[&str], out: &mut HashSet<String>) {
for entry in entries {
let trimmed = entry.trim();
if trimmed.is_empty() {
continue;
}
let name = trimmed.rsplit("::").next().unwrap_or(trimmed);
if !name.is_empty() {
out.insert(name.to_string());
}
}
}
fn remove_generated_type_files(
out_dir: &Path,
type_names: &HashSet<String>,
extension: &str,
) -> Result<()> {
for type_name in type_names {
for subdir in ["", "v1", "v2"] {
let path = if subdir.is_empty() {
out_dir.join(format!("{type_name}.{extension}"))
} else {
out_dir
.join(subdir)
.join(format!("{type_name}.{extension}"))
};
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("Failed to remove {}", path.display()))?;
}
}
}
Ok(())
}
fn remove_experimental_method_type_definitions(bundle: &mut Value) {
let type_names = experimental_method_types();
let Some(definitions) = bundle.get_mut("definitions").and_then(Value::as_object_mut) else {
return;
};
remove_experimental_method_type_definitions_map(definitions, &type_names);
}
fn remove_experimental_method_type_definitions_map(
definitions: &mut Map<String, Value>,
experimental_type_names: &HashSet<String>,
) {
let keys_to_remove: Vec<String> = definitions
.keys()
.filter(|def_name| {
experimental_type_names
.iter()
.any(|type_name| definition_matches_type(def_name, type_name))
})
.cloned()
.collect();
for key in keys_to_remove {
definitions.remove(&key);
}
for value in definitions.values_mut() {
if !is_namespace_map(value) {
continue;
}
if let Some(namespace_defs) = value.as_object_mut() {
remove_experimental_method_type_definitions_map(
namespace_defs,
experimental_type_names,
);
}
}
}
fn prune_unused_type_imports(content: String, type_alias_body: &str) -> String {
let trailing_newline = content.ends_with('\n');
let mut lines = Vec::new();
for line in content.lines() {
if let Some(type_name) = parse_imported_type_name(line)
&& !type_alias_body.contains(type_name)
{
continue;
}
lines.push(line);
}
let mut rewritten = lines.join("\n");
if trailing_newline {
rewritten.push('\n');
}
rewritten
}
fn parse_imported_type_name(line: &str) -> Option<&str> {
let line = line.trim();
let rest = line.strip_prefix("import type {")?;
let (type_name, _) = rest.split_once("} from ")?;
let type_name = type_name.trim();
if type_name.is_empty() || type_name.contains(',') || type_name.contains(" as ") {
return None;
}
Some(type_name)
}
fn json_files_in_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
for entry in fs::read_dir(&current)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if matches!(path.extension().and_then(|ext| ext.to_str()), Some("json")) {
out.push(path);
}
}
}
Ok(out)
}
fn read_json_value(path: &Path) -> Result<Value> {
let content =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
}
fn split_type_alias(content: &str) -> Option<(String, String, String)> {
let eq_index = content.find('=')?;
let semi_index = content.rfind(';')?;
if semi_index <= eq_index {
return None;
}
let prefix = content[..eq_index + 1].to_string();
let body = content[eq_index + 1..semi_index].to_string();
let suffix = content[semi_index..].to_string();
Some((prefix, body, suffix))
}
fn type_body_brace_span(content: &str) -> Option<(usize, usize)> {
if let Some(eq_index) = content.find('=') {
let after_eq = &content[eq_index + 1..];
let (open_rel, close_rel) = find_top_level_brace_span(after_eq)?;
return Some((eq_index + 1 + open_rel, eq_index + 1 + close_rel));
}
const INTERFACE_MARKER: &str = "export interface";
let interface_index = content.find(INTERFACE_MARKER)?;
let after_interface = &content[interface_index + INTERFACE_MARKER.len()..];
let (open_rel, close_rel) = find_top_level_brace_span(after_interface)?;
Some((
interface_index + INTERFACE_MARKER.len() + open_rel,
interface_index + INTERFACE_MARKER.len() + close_rel,
))
}
fn find_top_level_brace_span(input: &str) -> Option<(usize, usize)> {
let mut state = ScanState::default();
let mut open_index = None;
for (index, ch) in input.char_indices() {
if !state.in_string() && ch == '{' && state.depth.is_top_level() {
open_index = Some(index);
}
state.observe(ch);
if !state.in_string()
&& ch == '}'
&& state.depth.is_top_level()
&& let Some(open) = open_index
{
return Some((open, index));
}
}
None
}
fn split_top_level(input: &str, delimiter: char) -> Vec<String> {
split_top_level_multi(input, &[delimiter])
}
fn split_top_level_multi(input: &str, delimiters: &[char]) -> Vec<String> {
let mut state = ScanState::default();
let mut start = 0usize;
let mut parts = Vec::new();
for (index, ch) in input.char_indices() {
if !state.in_string() && state.depth.is_top_level() && delimiters.contains(&ch) {
let part = input[start..index].trim();
if !part.is_empty() {
parts.push(part.to_string());
}
start = index + ch.len_utf8();
}
state.observe(ch);
}
let tail = input[start..].trim();
if !tail.is_empty() {
parts.push(tail.to_string());
}
parts
}
fn extract_method_from_arm(arm: &str) -> Option<String> {
let (open, close) = find_top_level_brace_span(arm)?;
let inner = &arm[open + 1..close];
for field in split_top_level(inner, ',') {
let Some((name, value)) = parse_property(field.as_str()) else {
continue;
};
if name != "method" {
continue;
}
let value = value.trim_start();
let (literal, _) = parse_string_literal(value)?;
return Some(literal);
}
None
}
fn parse_property(input: &str) -> Option<(String, &str)> {
let name = parse_property_name(input)?;
let colon_index = input.find(':')?;
Some((name, input[colon_index + 1..].trim_start()))
}
fn strip_leading_block_comments(input: &str) -> &str {
let mut rest = input.trim_start();
loop {
let Some(after_prefix) = rest.strip_prefix("/*") else {
return rest;
};
let Some(end_rel) = after_prefix.find("*/") else {
return rest;
};
rest = after_prefix[end_rel + 2..].trim_start();
}
}
fn parse_property_name(input: &str) -> Option<String> {
let trimmed = input.trim_start();
if trimmed.is_empty() {
return None;
}
if let Some((literal, consumed)) = parse_string_literal(trimmed) {
let rest = trimmed[consumed..].trim_start();
if rest.starts_with(':') {
return Some(literal);
}
return None;
}
let mut end = 0usize;
for (index, ch) in trimmed.char_indices() {
if !is_ident_char(ch) {
break;
}
end = index + ch.len_utf8();
}
if end == 0 {
return None;
}
let name = &trimmed[..end];
let rest = trimmed[end..].trim_start();
let rest = if let Some(stripped) = rest.strip_prefix('?') {
stripped.trim_start()
} else {
rest
};
if rest.starts_with(':') {
return Some(name.to_string());
}
None
}
fn parse_string_literal(input: &str) -> Option<(String, usize)> {
let mut chars = input.char_indices();
let (start_index, quote) = chars.next()?;
if quote != '"' && quote != '\'' {
return None;
}
let mut escape = false;
for (index, ch) in chars {
if escape {
escape = false;
continue;
}
if ch == '\\' {
escape = true;
continue;
}
if ch == quote {
let literal = input[start_index + 1..index].to_string();
let consumed = index + ch.len_utf8();
return Some((literal, consumed));
}
}
None
}
fn is_ident_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_'
}
#[derive(Default)]
struct ScanState {
depth: Depth,
string_delim: Option<char>,
escape: bool,
}
impl ScanState {
fn observe(&mut self, ch: char) {
if let Some(delim) = self.string_delim {
if self.escape {
self.escape = false;
return;
}
if ch == '\\' {
self.escape = true;
return;
}
if ch == delim {
self.string_delim = None;
}
return;
}
match ch {
'"' | '\'' => {
self.string_delim = Some(ch);
}
'{' => self.depth.brace += 1,
'}' => self.depth.brace = (self.depth.brace - 1).max(0),
'[' => self.depth.bracket += 1,
']' => self.depth.bracket = (self.depth.bracket - 1).max(0),
'(' => self.depth.paren += 1,
')' => self.depth.paren = (self.depth.paren - 1).max(0),
'<' => self.depth.angle += 1,
'>' => {
if self.depth.angle > 0 {
self.depth.angle -= 1;
}
}
_ => {}
}
}
fn in_string(&self) -> bool {
self.string_delim.is_some()
}
}
#[derive(Default)]
struct Depth {
brace: i32,
bracket: i32,
paren: i32,
angle: i32,
}
impl Depth {
fn is_top_level(&self) -> bool {
self.brace == 0 && self.bracket == 0 && self.paren == 0 && self.angle == 0
}
}
fn build_schema_bundle(schemas: Vec<GeneratedSchema>) -> Result<Value> {
const SPECIAL_DEFINITIONS: &[&str] = &[
"ClientNotification",
@@ -740,7 +1393,9 @@ fn generate_index_ts(out_dir: &Path) -> Result<PathBuf> {
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::v2;
use anyhow::Result;
use pretty_assertions::assert_eq;
use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;
@@ -767,9 +1422,34 @@ mod tests {
generate_indices: false,
ensure_headers: false,
run_prettier: false,
experimental_api: false,
};
generate_ts_with_options(&output_dir, None, options)?;
let client_request_ts = fs::read_to_string(output_dir.join("ClientRequest.ts"))?;
assert_eq!(client_request_ts.contains("mock/experimentalMethod"), false);
assert_eq!(
client_request_ts.contains("MockExperimentalMethodParams"),
false
);
let thread_start_ts =
fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.ts"))?;
assert_eq!(thread_start_ts.contains("mockExperimentalField"), false);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodParams.ts")
.exists(),
false
);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodResponse.ts")
.exists(),
false
);
let mut undefined_offenders = Vec::new();
let mut optional_nullable_offenders = BTreeSet::new();
let mut stack = vec![output_dir];
@@ -943,4 +1623,174 @@ mod tests {
Ok(())
}
#[test]
fn generate_ts_with_experimental_api_retains_experimental_entries() -> Result<()> {
let output_dir =
std::env::temp_dir().join(format!("codex_ts_types_experimental_{}", Uuid::now_v7()));
fs::create_dir(&output_dir)?;
struct TempDirGuard(PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
let _guard = TempDirGuard(output_dir.clone());
let options = GenerateTsOptions {
generate_indices: false,
ensure_headers: false,
run_prettier: false,
experimental_api: true,
};
generate_ts_with_options(&output_dir, None, options)?;
let client_request_ts = fs::read_to_string(output_dir.join("ClientRequest.ts"))?;
assert_eq!(client_request_ts.contains("mock/experimentalMethod"), true);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodParams.ts")
.exists(),
true
);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodResponse.ts")
.exists(),
true
);
let thread_start_ts =
fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.ts"))?;
assert_eq!(thread_start_ts.contains("mockExperimentalField"), true);
Ok(())
}
#[test]
fn stable_schema_filter_removes_mock_thread_start_field() -> Result<()> {
let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7()));
fs::create_dir(&output_dir)?;
let schema = write_json_schema_with_return::<v2::ThreadStartParams>(
&output_dir,
"ThreadStartParams",
)?;
let mut bundle = build_schema_bundle(vec![schema])?;
filter_experimental_schema(&mut bundle)?;
let definitions = bundle["definitions"]
.as_object()
.expect("schema bundle should include definitions");
let (_, def_schema) = definitions
.iter()
.find(|(name, _)| definition_matches_type(name, "ThreadStartParams"))
.expect("ThreadStartParams definition should exist");
let properties = def_schema["properties"]
.as_object()
.expect("ThreadStartParams should have properties");
assert_eq!(properties.contains_key("mockExperimentalField"), false);
let _cleanup = fs::remove_dir_all(&output_dir);
Ok(())
}
#[test]
fn experimental_type_fields_ts_filter_handles_interface_shape() -> Result<()> {
let output_dir = std::env::temp_dir().join(format!("codex_ts_filter_{}", Uuid::now_v7()));
fs::create_dir_all(&output_dir)?;
struct TempDirGuard(PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
let _guard = TempDirGuard(output_dir.clone());
let path = output_dir.join("CustomParams.ts");
let content = r#"export interface CustomParams {
stableField: string | null;
unstableField: string | null;
otherStableField: boolean;
}
"#;
fs::write(&path, content)?;
static CUSTOM_FIELD: crate::experimental_api::ExperimentalField =
crate::experimental_api::ExperimentalField {
type_name: "CustomParams",
field_name: "unstableField",
reason: "custom/unstableField",
};
filter_experimental_type_fields_ts(&output_dir, &[&CUSTOM_FIELD])?;
let filtered = fs::read_to_string(&path)?;
assert_eq!(filtered.contains("unstableField"), false);
assert_eq!(filtered.contains("stableField"), true);
assert_eq!(filtered.contains("otherStableField"), true);
Ok(())
}
#[test]
fn stable_schema_filter_removes_mock_experimental_method() -> Result<()> {
let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7()));
fs::create_dir(&output_dir)?;
let schema =
write_json_schema_with_return::<crate::ClientRequest>(&output_dir, "ClientRequest")?;
let mut bundle = build_schema_bundle(vec![schema])?;
filter_experimental_schema(&mut bundle)?;
let bundle_str = serde_json::to_string(&bundle)?;
assert_eq!(bundle_str.contains("mock/experimentalMethod"), false);
let _cleanup = fs::remove_dir_all(&output_dir);
Ok(())
}
#[test]
fn generate_json_filters_experimental_fields_and_methods() -> Result<()> {
let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7()));
fs::create_dir(&output_dir)?;
generate_json_with_experimental(&output_dir, false)?;
let thread_start_json =
fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.json"))?;
assert_eq!(thread_start_json.contains("mockExperimentalField"), false);
let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?;
assert_eq!(
client_request_json.contains("mock/experimentalMethod"),
false
);
let bundle_json =
fs::read_to_string(output_dir.join("codex_app_server_protocol.schemas.json"))?;
assert_eq!(bundle_json.contains("mockExperimentalField"), false);
assert_eq!(bundle_json.contains("MockExperimentalMethodParams"), false);
assert_eq!(
bundle_json.contains("MockExperimentalMethodResponse"),
false
);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodParams.json")
.exists(),
false
);
assert_eq!(
output_dir
.join("v2")
.join("MockExperimentalMethodResponse.json")
.exists(),
false
);
let _cleanup = fs::remove_dir_all(&output_dir);
Ok(())
}
}