Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,6 @@ mod thread_session_state;
use self::agent_navigation::AgentNavigationDirection;
use self::agent_navigation::AgentNavigationState;
use self::app_server_requests::PendingAppServerRequests;
#[cfg(test)]
use self::background_requests::*;
use self::loaded_threads::find_loaded_subagent_threads_for_primary;
use self::pending_interactive_replay::PendingInteractiveReplayState;
use self::platform_actions::*;
Expand Down Expand Up @@ -1152,5 +1150,7 @@ impl Drop for App {
}
}

#[cfg(test)]
pub(super) mod test_support;
#[cfg(test)]
mod tests;
145 changes: 145 additions & 0 deletions codex-rs/tui/src/app/background_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -637,3 +637,148 @@ pub(super) fn mcp_inventory_maps_from_statuses(statuses: Vec<McpServerStatus>) -

(tools, resources, resource_templates, auth_statuses)
}

#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_protocol::mcp::Tool;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;

fn test_absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
}

#[test]
fn hide_cli_only_plugin_marketplaces_removes_openai_bundled() {
let mut response = PluginListResponse {
marketplaces: vec![
PluginMarketplaceEntry {
name: "openai-bundled".to_string(),
path: Some(test_absolute_path("/marketplaces/openai-bundled")),
interface: None,
plugins: Vec::new(),
},
PluginMarketplaceEntry {
name: "openai-curated".to_string(),
path: Some(test_absolute_path("/marketplaces/openai-curated")),
interface: None,
plugins: Vec::new(),
},
],
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
};

hide_cli_only_plugin_marketplaces(&mut response);

assert_eq!(
response.marketplaces,
vec![PluginMarketplaceEntry {
name: "openai-curated".to_string(),
path: Some(test_absolute_path("/marketplaces/openai-curated")),
interface: None,
plugins: Vec::new(),
}]
);
}

#[test]
fn mcp_inventory_maps_prefix_tool_names_by_server() {
let statuses = vec![
McpServerStatus {
name: "docs".to_string(),
tools: HashMap::from([(
"list".to_string(),
Tool {
description: None,
name: "list".to_string(),
title: None,
input_schema: serde_json::json!({"type": "object"}),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
)]),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
},
McpServerStatus {
name: "disabled".to_string(),
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
},
];

let (tools, resources, resource_templates, auth_statuses) =
mcp_inventory_maps_from_statuses(statuses);
let mut resource_names = resources.keys().cloned().collect::<Vec<_>>();
resource_names.sort();
let mut template_names = resource_templates.keys().cloned().collect::<Vec<_>>();
template_names.sort();

assert_eq!(
tools.keys().cloned().collect::<Vec<_>>(),
vec!["mcp__docs__list".to_string()]
);
assert_eq!(resource_names, vec!["disabled", "docs"]);
assert_eq!(template_names, vec!["disabled", "docs"]);
assert_eq!(
auth_statuses.get("disabled"),
Some(&McpAuthStatus::Unsupported)
);
}

#[test]
fn build_feedback_upload_params_includes_thread_id_and_rollout_path() {
let thread_id = ThreadId::new();
let rollout_path = PathBuf::from("/tmp/rollout.jsonl");

let params = build_feedback_upload_params(
Some(thread_id),
Some(rollout_path.clone()),
FeedbackCategory::SafetyCheck,
Some("needs follow-up".to_string()),
Some("turn-123".to_string()),
/*include_logs*/ true,
);

assert_eq!(params.classification, "safety_check");
assert_eq!(params.reason, Some("needs follow-up".to_string()));
assert_eq!(params.thread_id, Some(thread_id.to_string()));
assert_eq!(
params
.tags
.as_ref()
.and_then(|tags| tags.get("turn_id"))
.map(String::as_str),
Some("turn-123")
);
assert_eq!(params.include_logs, true);
assert_eq!(params.extra_log_files, Some(vec![rollout_path]));
}

#[test]
fn build_feedback_upload_params_omits_rollout_path_without_logs() {
let params = build_feedback_upload_params(
/*origin_thread_id*/ None,
Some(PathBuf::from("/tmp/rollout.jsonl")),
FeedbackCategory::GoodResult,
/*reason*/ None,
/*turn_id*/ None,
/*include_logs*/ false,
);

assert_eq!(params.classification, "good_result");
assert_eq!(params.reason, None);
assert_eq!(params.thread_id, None);
assert_eq!(params.tags, None);
assert_eq!(params.include_logs, false);
assert_eq!(params.extra_log_files, None);
}
}
173 changes: 173 additions & 0 deletions codex-rs/tui/src/app/config_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -537,3 +537,176 @@ impl App {
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::app::test_support::app_enabled_in_effective_config;
use crate::app::test_support::make_test_app;
use crate::test_support::PathBufExt;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SessionConfiguredEvent;
use pretty_assertions::assert_eq;
use tempfile::tempdir;

#[tokio::test]
async fn update_reasoning_effort_updates_collaboration_mode() {
let mut app = make_test_app().await;
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));

app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));

assert_eq!(
app.chat_widget.current_reasoning_effort(),
Some(ReasoningEffortConfig::High)
);
assert_eq!(
app.config.model_reasoning_effort,
Some(ReasoningEffortConfig::High)
);
}

#[tokio::test]
async fn refresh_in_memory_config_from_disk_loads_latest_apps_state() -> Result<()> {
let mut app = make_test_app().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
let app_id = "unit_test_refresh_in_memory_config_connector".to_string();

assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);

ConfigEditsBuilder::new(&app.config.codex_home)
.with_edits([
ConfigEdit::SetPath {
segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()],
value: false.into(),
},
ConfigEdit::SetPath {
segments: vec![
"apps".to_string(),
app_id.clone(),
"disabled_reason".to_string(),
],
value: "user".into(),
},
])
.apply()
.await
.expect("persist app toggle");

assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);

app.refresh_in_memory_config_from_disk().await?;

assert_eq!(
app_enabled_in_effective_config(&app.config, &app_id),
Some(false)
);
Ok(())
}

#[tokio::test]
async fn refresh_in_memory_config_from_disk_best_effort_keeps_current_config_on_error()
-> Result<()> {
let mut app = make_test_app().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
std::fs::write(codex_home.path().join("config.toml"), "[broken")?;
let original_config = app.config.clone();

app.refresh_in_memory_config_from_disk_best_effort("starting a new thread")
.await;

assert_eq!(app.config, original_config);
Ok(())
}

#[tokio::test]
async fn refresh_in_memory_config_from_disk_uses_active_chat_widget_cwd() -> Result<()> {
let mut app = make_test_app().await;
let original_cwd = app.config.cwd.clone();
let next_cwd_tmp = tempdir()?;
let next_cwd = next_cwd_tmp.path().to_path_buf();

app.chat_widget.handle_codex_event(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: next_cwd.clone().abs(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
});

assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd);
assert_eq!(app.config.cwd, original_cwd);

app.refresh_in_memory_config_from_disk().await?;

assert_eq!(app.config.cwd, app.chat_widget.config_ref().cwd);
Ok(())
}

#[tokio::test]
async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error()
-> Result<()> {
let mut app = make_test_app().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
std::fs::write(codex_home.path().join("config.toml"), "[broken")?;
let current_config = app.config.clone();
let current_cwd = current_config.cwd.clone();

let resume_config = app
.rebuild_config_for_resume_or_fallback(&current_cwd, current_cwd.to_path_buf())
.await?;

assert_eq!(resume_config, current_config);
Ok(())
}

#[tokio::test]
async fn rebuild_config_for_resume_or_fallback_errors_when_cwd_changes() -> Result<()> {
let mut app = make_test_app().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
std::fs::write(codex_home.path().join("config.toml"), "[broken")?;
let current_cwd = app.config.cwd.clone();
let next_cwd_tmp = tempdir()?;
let next_cwd = next_cwd_tmp.path().to_path_buf();

let result = app
.rebuild_config_for_resume_or_fallback(&current_cwd, next_cwd)
.await;

assert!(result.is_err());
Ok(())
}

#[tokio::test]
async fn sync_tui_theme_selection_updates_chat_widget_config_copy() {
let mut app = make_test_app().await;

app.sync_tui_theme_selection("dracula".to_string());

assert_eq!(app.config.tui_theme.as_deref(), Some("dracula"));
assert_eq!(
app.chat_widget.config_ref().tui_theme.as_deref(),
Some("dracula")
);
}
}
35 changes: 35 additions & 0 deletions codex-rs/tui/src/app/platform_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,38 @@ pub(super) fn side_return_shortcut_matches(key_event: KeyEvent) -> bool {
_ => false,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn side_return_shortcuts_match_esc_and_ctrl_c() {
assert!(side_return_shortcut_matches(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::NONE,
)));
assert!(side_return_shortcut_matches(KeyEvent::new_with_kind(
KeyCode::Esc,
KeyModifiers::NONE,
KeyEventKind::Repeat,
)));
assert!(side_return_shortcut_matches(KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
)));
assert!(side_return_shortcut_matches(KeyEvent::new(
KeyCode::Char('C'),
KeyModifiers::CONTROL,
)));
assert!(!side_return_shortcut_matches(KeyEvent::new(
KeyCode::Char('d'),
KeyModifiers::CONTROL,
)));
assert!(!side_return_shortcut_matches(KeyEvent::new_with_kind(
KeyCode::Esc,
KeyModifiers::NONE,
KeyEventKind::Release,
)));
}
}
Loading
Loading