mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
## Why The TUI currently handles keyboard shortcuts as hard-coded event matches spread across app, composer, pager, list, approval, and navigation code. That makes shortcuts hard to customize, makes displayed hints easy to drift from actual behavior, and makes future keymap work riskier because there is no central action inventory. This PR adds the foundation for configurable, action-based keymaps without adding the interactive remapping UI yet. Onboarding intentionally stays on fixed startup shortcuts because users cannot reasonably configure keymaps before completing onboarding. This is PR1 in the keymap stack: - PR1: #18593: configurable keymap foundation - PR2: #18594: `/keymap` picker and guided remapping UI - PR3: #18595: Vim composer mode and the remap option ## Design Notes The new model resolves named actions into concrete runtime bindings once from config, then passes those bindings to the UI surfaces that handle input or render shortcut hints. The main concepts are: - **Context**: a scope where an action is active, such as `global`, `chat`, `composer`, `editor`, `pager`, `list`, or `approval`. - **Action**: a named operation inside a context, such as `global.open_transcript`, `composer.submit`, or `pager.close`. - **Binding**: one or more single-key shortcuts assigned to an action, written as config strings such as `ctrl-t`, `alt-backspace`, or `page-down`. Multi-step sequences such as `ctrl-x ctrl-s`, `g g`, or leader-key flows are not part of this PR. - **Resolution order**: context-specific config wins first, supported global fallbacks come next, and built-in defaults fill in anything unset. - **Explicit unbinding**: an empty array removes an action binding in that scope and does not fall through to a fallback binding. - **Conflict validation**: a resolved keymap rejects duplicate active bindings inside the same scope so one keypress cannot dispatch two actions. ## What Changed - Added `TuiKeymap` config support under `[tui.keymap]`, including typed contexts/actions, key alias normalization, generated schema coverage, and user-facing config errors. - Added `RuntimeKeymap` resolution in `codex-rs/tui/src/keymap.rs`, including fallback precedence, built-in defaults, explicit unbinding, and per-context conflict validation. - Rewired existing TUI handlers to consume resolved keymap actions instead of directly matching hard-coded keys in each component. - Updated key hint rendering and footer/pager/list surfaces so displayed shortcuts follow the resolved keymap. - Kept onboarding shortcuts fixed in `codex-rs/tui/src/onboarding/keys.rs` instead of exposing them through `[tui.keymap]`. ## Validation The branch includes focused coverage for config parsing, key normalization, runtime fallback resolution, explicit unbinding, duplicate-key conflict validation, default keymap consistency, onboarding startup key behavior, and UI hint snapshots affected by resolved key bindings.
1436 lines
40 KiB
Rust
1436 lines
40 KiB
Rust
use super::*;
|
|
use codex_config::types::AppToolApproval;
|
|
use codex_config::types::McpServerToolConfig;
|
|
use codex_config::types::McpServerTransportConfig;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use pretty_assertions::assert_eq;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::symlink;
|
|
use tempfile::tempdir;
|
|
use toml::Value as TomlValue;
|
|
|
|
#[test]
|
|
fn blocking_set_model_top_level() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetModel {
|
|
model: Some("gpt-5.4".to_string()),
|
|
effort: Some(ReasoningEffort::High),
|
|
}],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"model = "gpt-5.4"
|
|
model_reasoning_effort = "high"
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn builder_with_edits_applies_custom_paths() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.with_edits(vec![ConfigEdit::SetPath {
|
|
segments: vec!["enabled".to_string()],
|
|
value: value(true),
|
|
}])
|
|
.apply_blocking()
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
assert_eq!(contents, "enabled = true\n");
|
|
}
|
|
|
|
#[test]
|
|
fn keymap_binding_edit_writes_root_action_binding() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.with_edits([keymap_binding_edit("composer", "submit", "ctrl-enter")])
|
|
.apply_blocking()
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[tui.keymap.composer]
|
|
submit = "ctrl-enter"
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn keymap_bindings_edit_writes_single_binding_as_string() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.with_edits([keymap_bindings_edit(
|
|
"composer",
|
|
"submit",
|
|
&["ctrl-enter".to_string()],
|
|
)])
|
|
.apply_blocking()
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[tui.keymap.composer]
|
|
submit = "ctrl-enter"
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn keymap_bindings_edit_writes_multiple_bindings_as_array() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.with_edits([keymap_bindings_edit(
|
|
"composer",
|
|
"submit",
|
|
&["enter".to_string(), "ctrl-enter".to_string()],
|
|
)])
|
|
.apply_blocking()
|
|
.expect("persist");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let value: TomlValue = toml::from_str(&raw).expect("parse config");
|
|
|
|
assert_eq!(
|
|
value
|
|
.get("tui")
|
|
.and_then(|value| value.get("keymap"))
|
|
.and_then(|value| value.get("composer"))
|
|
.and_then(|value| value.get("submit"))
|
|
.and_then(TomlValue::as_array)
|
|
.map(|values| {
|
|
values
|
|
.iter()
|
|
.filter_map(TomlValue::as_str)
|
|
.collect::<Vec<_>>()
|
|
}),
|
|
Some(vec!["enter", "ctrl-enter"])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keymap_binding_edit_replaces_existing_binding_without_touching_profile() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"profile = "team"
|
|
|
|
[tui.keymap.composer]
|
|
submit = "enter"
|
|
|
|
[profiles.team.tui.keymap.composer]
|
|
submit = "shift-enter"
|
|
"#,
|
|
)
|
|
.expect("seed config");
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.with_edits([keymap_binding_edit("composer", "submit", "ctrl-enter")])
|
|
.apply_blocking()
|
|
.expect("persist");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let value: TomlValue = toml::from_str(&raw).expect("parse config");
|
|
|
|
assert_eq!(
|
|
value
|
|
.get("tui")
|
|
.and_then(|value| value.get("keymap"))
|
|
.and_then(|value| value.get("composer"))
|
|
.and_then(|value| value.get("submit"))
|
|
.and_then(TomlValue::as_str),
|
|
Some("ctrl-enter")
|
|
);
|
|
assert_eq!(
|
|
value
|
|
.get("profiles")
|
|
.and_then(|value| value.get("team"))
|
|
.and_then(|value| value.get("tui"))
|
|
.and_then(|value| value.get("keymap"))
|
|
.and_then(|value| value.get("composer"))
|
|
.and_then(|value| value.get("submit"))
|
|
.and_then(TomlValue::as_str),
|
|
Some("shift-enter")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keymap_binding_clear_edit_removes_root_action_binding_without_touching_profile() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"profile = "team"
|
|
|
|
[tui.keymap.composer]
|
|
submit = "enter"
|
|
|
|
[profiles.team.tui.keymap.composer]
|
|
submit = "shift-enter"
|
|
"#,
|
|
)
|
|
.expect("seed config");
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.with_edits([keymap_binding_clear_edit("composer", "submit")])
|
|
.apply_blocking()
|
|
.expect("persist");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let value: TomlValue = toml::from_str(&raw).expect("parse config");
|
|
|
|
assert_eq!(
|
|
value
|
|
.get("tui")
|
|
.and_then(|value| value.get("keymap"))
|
|
.and_then(|value| value.get("composer"))
|
|
.and_then(|value| value.get("submit")),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
value
|
|
.get("profiles")
|
|
.and_then(|value| value.get("team"))
|
|
.and_then(|value| value.get("tui"))
|
|
.and_then(|value| value.get("keymap"))
|
|
.and_then(|value| value.get("composer"))
|
|
.and_then(|value| value.get("submit"))
|
|
.and_then(TomlValue::as_str),
|
|
Some("shift-enter")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_model_availability_nux_count_writes_shown_count() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
let shown_count = HashMap::from([("gpt-foo".to_string(), 4)]);
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.set_model_availability_nux_count(&shown_count)
|
|
.apply_blocking()
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[tui.model_availability_nux]
|
|
gpt-foo = 4
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn set_skill_config_writes_disabled_entry() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.with_edits([ConfigEdit::SetSkillConfig {
|
|
path: PathBuf::from("/tmp/skills/demo/SKILL.md"),
|
|
enabled: false,
|
|
}])
|
|
.apply_blocking()
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[[skills.config]]
|
|
path = "/tmp/skills/demo/SKILL.md"
|
|
enabled = false
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn set_skill_config_removes_entry_when_enabled() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[[skills.config]]
|
|
path = "/tmp/skills/demo/SKILL.md"
|
|
enabled = false
|
|
"#,
|
|
)
|
|
.expect("seed config");
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.with_edits([ConfigEdit::SetSkillConfig {
|
|
path: PathBuf::from("/tmp/skills/demo/SKILL.md"),
|
|
enabled: true,
|
|
}])
|
|
.apply_blocking()
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
assert_eq!(contents, "");
|
|
}
|
|
|
|
#[test]
|
|
fn set_skill_config_writes_name_selector_entry() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.with_edits([ConfigEdit::SetSkillConfigByName {
|
|
name: "github:yeet".to_string(),
|
|
enabled: false,
|
|
}])
|
|
.apply_blocking()
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[[skills.config]]
|
|
name = "github:yeet"
|
|
enabled = false
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_model_preserves_inline_table_contents() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
// Seed with inline tables for profiles to simulate common user config.
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"profile = "fast"
|
|
|
|
profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetModel {
|
|
model: Some("o4-mini".to_string()),
|
|
effort: None,
|
|
}],
|
|
)
|
|
.expect("persist");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let value: TomlValue = toml::from_str(&raw).expect("parse config");
|
|
|
|
// Ensure sandbox_mode is preserved under profiles.fast and model updated.
|
|
let profiles_tbl = value
|
|
.get("profiles")
|
|
.and_then(|v| v.as_table())
|
|
.expect("profiles table");
|
|
let fast_tbl = profiles_tbl
|
|
.get("fast")
|
|
.and_then(|v| v.as_table())
|
|
.expect("fast table");
|
|
assert_eq!(
|
|
fast_tbl.get("sandbox_mode").and_then(|v| v.as_str()),
|
|
Some("strict")
|
|
);
|
|
assert_eq!(
|
|
fast_tbl.get("model").and_then(|v| v.as_str()),
|
|
Some("o4-mini")
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn blocking_set_model_writes_through_symlink_chain() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
let target_dir = tempdir().expect("target dir");
|
|
let target_path = target_dir.path().join(CONFIG_TOML_FILE);
|
|
let link_path = codex_home.join("config-link.toml");
|
|
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
|
|
|
symlink(&target_path, &link_path).expect("symlink link");
|
|
symlink("config-link.toml", &config_path).expect("symlink config");
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetModel {
|
|
model: Some("gpt-5.4".to_string()),
|
|
effort: Some(ReasoningEffort::High),
|
|
}],
|
|
)
|
|
.expect("persist");
|
|
|
|
let meta = std::fs::symlink_metadata(&config_path).expect("config metadata");
|
|
assert!(meta.file_type().is_symlink());
|
|
|
|
let contents = std::fs::read_to_string(&target_path).expect("read target");
|
|
let expected = r#"model = "gpt-5.4"
|
|
model_reasoning_effort = "high"
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn blocking_set_model_replaces_symlink_on_cycle() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
let link_a = codex_home.join("a.toml");
|
|
let link_b = codex_home.join("b.toml");
|
|
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
|
|
|
symlink("b.toml", &link_a).expect("symlink a");
|
|
symlink("a.toml", &link_b).expect("symlink b");
|
|
symlink("a.toml", &config_path).expect("symlink config");
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetModel {
|
|
model: Some("gpt-5.4".to_string()),
|
|
effort: None,
|
|
}],
|
|
)
|
|
.expect("persist");
|
|
|
|
let meta = std::fs::symlink_metadata(&config_path).expect("config metadata");
|
|
assert!(!meta.file_type().is_symlink());
|
|
|
|
let contents = std::fs::read_to_string(&config_path).expect("read config");
|
|
let expected = r#"model = "gpt-5.4"
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_write_table_upsert_preserves_inline_comments() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
let original = r#"approval_policy = "never"
|
|
|
|
[mcp_servers.linear]
|
|
name = "linear"
|
|
# ok
|
|
url = "https://linear.example"
|
|
|
|
[mcp_servers.linear.http_headers]
|
|
foo = "bar"
|
|
|
|
[sandbox_workspace_write]
|
|
# ok 3
|
|
network_access = false
|
|
"#;
|
|
std::fs::write(codex_home.join(CONFIG_TOML_FILE), original).expect("seed config");
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[
|
|
ConfigEdit::SetPath {
|
|
segments: vec![
|
|
"mcp_servers".to_string(),
|
|
"linear".to_string(),
|
|
"url".to_string(),
|
|
],
|
|
value: value("https://linear.example/v2"),
|
|
},
|
|
ConfigEdit::SetPath {
|
|
segments: vec![
|
|
"sandbox_workspace_write".to_string(),
|
|
"network_access".to_string(),
|
|
],
|
|
value: value(true),
|
|
},
|
|
],
|
|
)
|
|
.expect("apply");
|
|
|
|
let updated = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"approval_policy = "never"
|
|
|
|
[mcp_servers.linear]
|
|
name = "linear"
|
|
# ok
|
|
url = "https://linear.example/v2"
|
|
|
|
[mcp_servers.linear.http_headers]
|
|
foo = "bar"
|
|
|
|
[sandbox_workspace_write]
|
|
# ok 3
|
|
network_access = true
|
|
"#;
|
|
assert_eq!(updated, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_clear_model_removes_inline_table_entry() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"profile = "fast"
|
|
|
|
profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } }
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetModel {
|
|
model: None,
|
|
effort: Some(ReasoningEffort::High),
|
|
}],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"profile = "fast"
|
|
|
|
[profiles.fast]
|
|
sandbox_mode = "strict"
|
|
model_reasoning_effort = "high"
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_model_scopes_to_active_profile() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"profile = "team"
|
|
|
|
[profiles.team]
|
|
model_reasoning_effort = "low"
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetModel {
|
|
model: Some("o5-preview".to_string()),
|
|
effort: Some(ReasoningEffort::Minimal),
|
|
}],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"profile = "team"
|
|
|
|
[profiles.team]
|
|
model_reasoning_effort = "minimal"
|
|
model = "o5-preview"
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_model_with_explicit_profile() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[profiles."team a"]
|
|
model = "gpt-5.4"
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
Some("team a"),
|
|
&[ConfigEdit::SetModel {
|
|
model: Some("o4-mini".to_string()),
|
|
effort: None,
|
|
}],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[profiles."team a"]
|
|
model = "o4-mini"
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_hide_full_access_warning_preserves_table() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"# Global comment
|
|
|
|
[notice]
|
|
# keep me
|
|
existing = "value"
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetNoticeHideFullAccessWarning(true)],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"# Global comment
|
|
|
|
[notice]
|
|
# keep me
|
|
existing = "value"
|
|
hide_full_access_warning = true
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_hide_rate_limit_model_nudge_preserves_table() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[notice]
|
|
existing = "value"
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[notice]
|
|
existing = "value"
|
|
hide_rate_limit_model_nudge = true
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_hide_gpt5_1_migration_prompt_preserves_table() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[notice]
|
|
existing = "value"
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetNoticeHideModelMigrationPrompt(
|
|
"hide_gpt5_1_migration_prompt".to_string(),
|
|
true,
|
|
)],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[notice]
|
|
existing = "value"
|
|
hide_gpt5_1_migration_prompt = true
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_hide_gpt_5_1_codex_max_migration_prompt_preserves_table() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[notice]
|
|
existing = "value"
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetNoticeHideModelMigrationPrompt(
|
|
"hide_gpt-5.1-codex-max_migration_prompt".to_string(),
|
|
true,
|
|
)],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[notice]
|
|
existing = "value"
|
|
"hide_gpt-5.1-codex-max_migration_prompt" = true
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_record_model_migration_seen_preserves_table() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[notice]
|
|
existing = "value"
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::RecordModelMigrationSeen {
|
|
from: "gpt-5.2".to_string(),
|
|
to: "gpt-5.4".to_string(),
|
|
}],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[notice]
|
|
existing = "value"
|
|
|
|
[notice.model_migrations]
|
|
"gpt-5.2" = "gpt-5.4"
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_hide_external_config_migration_prompt_home_preserves_table() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[notice]
|
|
existing = "value"
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
|
|
true,
|
|
)],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[notice]
|
|
existing = "value"
|
|
|
|
[notice.external_config_migration_prompts]
|
|
home = true
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_hide_external_config_migration_prompt_project_preserves_table() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[notice]
|
|
existing = "value"
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[
|
|
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
|
|
"/Users/alexsong/code/skills".to_string(),
|
|
true,
|
|
),
|
|
],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[notice]
|
|
existing = "value"
|
|
|
|
[notice.external_config_migration_prompts.projects]
|
|
"/Users/alexsong/code/skills" = true
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_external_config_migration_prompt_home_last_prompted_at_preserves_table() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[notice]
|
|
existing = "value"
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(1_760_000_000)],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[notice]
|
|
existing = "value"
|
|
|
|
[notice.external_config_migration_prompts]
|
|
home_last_prompted_at = 1760000000
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_external_config_migration_prompt_project_last_prompted_at_preserves_table() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[notice]
|
|
existing = "value"
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[
|
|
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
|
|
"/Users/alexsong/code/skills".to_string(),
|
|
1_760_000_000,
|
|
),
|
|
],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[notice]
|
|
existing = "value"
|
|
|
|
[notice.external_config_migration_prompts.project_last_prompted_at]
|
|
"/Users/alexsong/code/skills" = 1760000000
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_replace_mcp_servers_round_trips() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
let mut servers = BTreeMap::new();
|
|
servers.insert(
|
|
"stdio".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::Stdio {
|
|
command: "cmd".to_string(),
|
|
args: vec!["--flag".to_string()],
|
|
env: Some(
|
|
[
|
|
("B".to_string(), "2".to_string()),
|
|
("A".to_string(), "1".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
),
|
|
env_vars: vec!["FOO".into()],
|
|
cwd: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: true,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: Some(vec!["one".to_string(), "two".to_string()]),
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
);
|
|
|
|
servers.insert(
|
|
"http".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url: "https://example.com".to_string(),
|
|
bearer_token_env_var: Some("TOKEN".to_string()),
|
|
http_headers: Some(
|
|
[("Z-Header".to_string(), "z".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
),
|
|
env_http_headers: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: false,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: Some(std::time::Duration::from_secs(5)),
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: Some(vec!["forbidden".to_string()]),
|
|
scopes: None,
|
|
oauth_resource: Some("https://resource.example.com".to_string()),
|
|
tools: HashMap::new(),
|
|
},
|
|
);
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
|
|
)
|
|
.expect("persist");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = "\
|
|
[mcp_servers.http]
|
|
url = \"https://example.com\"
|
|
bearer_token_env_var = \"TOKEN\"
|
|
enabled = false
|
|
startup_timeout_sec = 5.0
|
|
disabled_tools = [\"forbidden\"]
|
|
oauth_resource = \"https://resource.example.com\"
|
|
|
|
[mcp_servers.http.http_headers]
|
|
Z-Header = \"z\"
|
|
|
|
[mcp_servers.stdio]
|
|
command = \"cmd\"
|
|
args = [\"--flag\"]
|
|
env_vars = [\"FOO\"]
|
|
supports_parallel_tool_calls = true
|
|
enabled_tools = [\"one\", \"two\"]
|
|
|
|
[mcp_servers.stdio.env]
|
|
A = \"1\"
|
|
B = \"2\"
|
|
";
|
|
assert_eq!(raw, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
let mut servers = BTreeMap::new();
|
|
servers.insert(
|
|
"docs".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::Stdio {
|
|
command: "docs-server".to_string(),
|
|
args: Vec::new(),
|
|
env: None,
|
|
env_vars: Vec::new(),
|
|
cwd: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: Some(AppToolApproval::Prompt),
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::from([(
|
|
"search".to_string(),
|
|
McpServerToolConfig {
|
|
approval_mode: Some(AppToolApproval::Approve),
|
|
},
|
|
)]),
|
|
},
|
|
);
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::ReplaceMcpServers(servers)],
|
|
)
|
|
.expect("persist");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = "\
|
|
[mcp_servers.docs]
|
|
command = \"docs-server\"
|
|
default_tools_approval_mode = \"prompt\"
|
|
|
|
[mcp_servers.docs.tools.search]
|
|
approval_mode = \"approve\"
|
|
";
|
|
assert_eq!(raw, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_replace_mcp_servers_preserves_inline_comments() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[mcp_servers]
|
|
# keep me
|
|
foo = { command = "cmd" }
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
|
|
let mut servers = BTreeMap::new();
|
|
servers.insert(
|
|
"foo".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::Stdio {
|
|
command: "cmd".to_string(),
|
|
args: Vec::new(),
|
|
env: None,
|
|
env_vars: Vec::new(),
|
|
cwd: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
);
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::ReplaceMcpServers(servers)],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[mcp_servers]
|
|
# keep me
|
|
foo = { command = "cmd" }
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_replace_mcp_servers_preserves_inline_comment_suffix() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[mcp_servers]
|
|
foo = { command = "cmd" } # keep me
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
|
|
let mut servers = BTreeMap::new();
|
|
servers.insert(
|
|
"foo".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::Stdio {
|
|
command: "cmd".to_string(),
|
|
args: Vec::new(),
|
|
env: None,
|
|
env_vars: Vec::new(),
|
|
cwd: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: false,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
);
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::ReplaceMcpServers(servers)],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[mcp_servers]
|
|
foo = { command = "cmd" , enabled = false } # keep me
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_replace_mcp_servers_preserves_inline_comment_after_removing_keys() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[mcp_servers]
|
|
foo = { command = "cmd", args = ["--flag"] } # keep me
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
|
|
let mut servers = BTreeMap::new();
|
|
servers.insert(
|
|
"foo".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::Stdio {
|
|
command: "cmd".to_string(),
|
|
args: Vec::new(),
|
|
env: None,
|
|
env_vars: Vec::new(),
|
|
cwd: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
);
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::ReplaceMcpServers(servers)],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[mcp_servers]
|
|
foo = { command = "cmd"} # keep me
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_replace_mcp_servers_preserves_inline_comment_prefix_on_update() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[mcp_servers]
|
|
# keep me
|
|
foo = { command = "cmd" }
|
|
"#,
|
|
)
|
|
.expect("seed");
|
|
|
|
let mut servers = BTreeMap::new();
|
|
servers.insert(
|
|
"foo".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::Stdio {
|
|
command: "cmd".to_string(),
|
|
args: Vec::new(),
|
|
env: None,
|
|
env_vars: Vec::new(),
|
|
cwd: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: false,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
);
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::ReplaceMcpServers(servers)],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"[mcp_servers]
|
|
# keep me
|
|
foo = { command = "cmd" , enabled = false }
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_clear_path_noop_when_missing() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::ClearPath {
|
|
segments: vec!["missing".to_string()],
|
|
}],
|
|
)
|
|
.expect("apply");
|
|
|
|
assert!(
|
|
!codex_home.join(CONFIG_TOML_FILE).exists(),
|
|
"config.toml should not be created on noop"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_set_path_updates_notifications() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
let item = value(false);
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::SetPath {
|
|
segments: vec!["tui".to_string(), "notifications".to_string()],
|
|
value: item,
|
|
}],
|
|
)
|
|
.expect("apply");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let config: TomlValue = toml::from_str(&raw).expect("parse config");
|
|
let notifications = config
|
|
.get("tui")
|
|
.and_then(|item| item.as_table())
|
|
.and_then(|tbl| tbl.get("notifications"))
|
|
.and_then(toml::Value::as_bool);
|
|
assert_eq!(notifications, Some(false));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn async_builder_set_model_persists() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path().to_path_buf();
|
|
|
|
ConfigEditsBuilder::new(&codex_home)
|
|
.set_model(Some("gpt-5.4"), Some(ReasoningEffort::High))
|
|
.apply()
|
|
.await
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"model = "gpt-5.4"
|
|
model_reasoning_effort = "high"
|
|
"#;
|
|
assert_eq!(contents, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_builder_set_model_round_trips_back_and_forth() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
let initial_expected = r#"model = "o4-mini"
|
|
model_reasoning_effort = "low"
|
|
"#;
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.set_model(Some("o4-mini"), Some(ReasoningEffort::Low))
|
|
.apply_blocking()
|
|
.expect("persist initial");
|
|
let mut contents =
|
|
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
assert_eq!(contents, initial_expected);
|
|
|
|
let updated_expected = r#"model = "gpt-5.4"
|
|
model_reasoning_effort = "high"
|
|
"#;
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.set_model(Some("gpt-5.4"), Some(ReasoningEffort::High))
|
|
.apply_blocking()
|
|
.expect("persist update");
|
|
contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
assert_eq!(contents, updated_expected);
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.set_model(Some("o4-mini"), Some(ReasoningEffort::Low))
|
|
.apply_blocking()
|
|
.expect("persist revert");
|
|
contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
assert_eq!(contents, initial_expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn blocking_set_asynchronous_helpers_available() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path().to_path_buf();
|
|
|
|
ConfigEditsBuilder::new(&codex_home)
|
|
.set_hide_full_access_warning(/*acknowledged*/ true)
|
|
.apply()
|
|
.await
|
|
.expect("persist");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let notice = toml::from_str::<TomlValue>(&raw)
|
|
.expect("parse config")
|
|
.get("notice")
|
|
.and_then(|item| item.as_table())
|
|
.and_then(|tbl| tbl.get("hide_full_access_warning"))
|
|
.and_then(toml::Value::as_bool);
|
|
assert_eq!(notice, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_builder_set_realtime_audio_persists_and_clears() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.set_realtime_microphone(Some("USB Mic"))
|
|
.set_realtime_speaker(Some("Desk Speakers"))
|
|
.apply_blocking()
|
|
.expect("persist realtime audio");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let config: TomlValue = toml::from_str(&raw).expect("parse config");
|
|
let realtime_audio = config
|
|
.get("audio")
|
|
.and_then(TomlValue::as_table)
|
|
.expect("audio table should exist");
|
|
assert_eq!(
|
|
realtime_audio.get("microphone").and_then(TomlValue::as_str),
|
|
Some("USB Mic")
|
|
);
|
|
assert_eq!(
|
|
realtime_audio.get("speaker").and_then(TomlValue::as_str),
|
|
Some("Desk Speakers")
|
|
);
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.set_realtime_microphone(/*microphone*/ None)
|
|
.apply_blocking()
|
|
.expect("clear realtime microphone");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let config: TomlValue = toml::from_str(&raw).expect("parse config");
|
|
let realtime_audio = config
|
|
.get("audio")
|
|
.and_then(TomlValue::as_table)
|
|
.expect("audio table should exist");
|
|
assert_eq!(realtime_audio.get("microphone"), None);
|
|
assert_eq!(
|
|
realtime_audio.get("speaker").and_then(TomlValue::as_str),
|
|
Some("Desk Speakers")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_builder_set_realtime_voice_persists_and_clears() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.set_realtime_voice(Some("cedar"))
|
|
.apply_blocking()
|
|
.expect("persist realtime voice");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let config: TomlValue = toml::from_str(&raw).expect("parse config");
|
|
let realtime = config
|
|
.get("realtime")
|
|
.and_then(TomlValue::as_table)
|
|
.expect("realtime table should exist");
|
|
assert_eq!(
|
|
realtime.get("voice").and_then(TomlValue::as_str),
|
|
Some("cedar")
|
|
);
|
|
|
|
ConfigEditsBuilder::new(codex_home)
|
|
.set_realtime_voice(/*voice*/ None)
|
|
.apply_blocking()
|
|
.expect("clear realtime voice");
|
|
|
|
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
let config: TomlValue = toml::from_str(&raw).expect("parse config");
|
|
let realtime = config
|
|
.get("realtime")
|
|
.and_then(TomlValue::as_table)
|
|
.expect("realtime table should exist");
|
|
assert_eq!(realtime.get("voice"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn replace_mcp_servers_blocking_clears_table_when_empty() {
|
|
let tmp = tempdir().expect("tmpdir");
|
|
let codex_home = tmp.path();
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
"[mcp_servers]\nfoo = { command = \"cmd\" }\n",
|
|
)
|
|
.expect("seed");
|
|
|
|
apply_blocking(
|
|
codex_home,
|
|
/*profile*/ None,
|
|
&[ConfigEdit::ReplaceMcpServers(BTreeMap::new())],
|
|
)
|
|
.expect("persist");
|
|
|
|
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
|
assert!(!contents.contains("mcp_servers"));
|
|
}
|