mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Move TUI app tests to modules they cover (#18799)
## Summary The TUI app refactor in #18753 moved the old `app.rs` tests into a single `app/tests.rs` file. That kept the split mechanically simple, but it left several focused unit tests far from the modules they exercise. This PR is a follow-up that moves tests next to the code they cover. It also adds `tui/src/app/test_support.rs` for shared fixture construction. This is just a mechanical refactoring (no functional changes) and does not affect any production code.
This commit is contained in:
committed by
GitHub
Unverified
parent
10e1659d4f
commit
4ed722ab8d
@@ -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::*;
|
||||
@@ -1152,5 +1150,7 @@ impl Drop for App {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) mod test_support;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t_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(¤t_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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,3 +730,61 @@ impl App {
|
||||
Ok(AppRunControl::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn terminal_thread_read_error_detection_matches_not_loaded_errors() {
|
||||
let err = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123"
|
||||
);
|
||||
|
||||
assert!(App::is_terminal_thread_read_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_thread_read_error_detection_ignores_transient_failures() {
|
||||
let err = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read transport error: broken pipe"
|
||||
);
|
||||
|
||||
assert!(!App::is_terminal_thread_read_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_state_for_thread_read_error_preserves_live_state_without_cache_on_transient_error() {
|
||||
let err = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read transport error: broken pipe"
|
||||
);
|
||||
|
||||
assert!(!App::closed_state_for_thread_read_error(
|
||||
&err, /*existing_is_closed*/ None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_state_for_thread_read_error_marks_terminal_uncached_threads_closed() {
|
||||
let err = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123"
|
||||
);
|
||||
|
||||
assert!(App::closed_state_for_thread_read_error(
|
||||
&err, /*existing_is_closed*/ None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_turns_fallback_detection_handles_unmaterialized_and_ephemeral_threads() {
|
||||
let unmaterialized = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read failed: thread thr_123 is not materialized yet; includeTurns is unavailable before first user message"
|
||||
);
|
||||
let ephemeral = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read failed: ephemeral threads do not support includeTurns"
|
||||
);
|
||||
|
||||
assert!(App::can_fallback_from_include_turns_error(&unmaterialized));
|
||||
assert!(App::can_fallback_from_include_turns_error(&ephemeral));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,57 @@ impl SideParentStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn side_boundary_prompt_marks_inherited_history_reference_only() {
|
||||
let item = App::side_boundary_prompt_item();
|
||||
let ResponseItem::Message { role, content, .. } = item else {
|
||||
panic!("expected hidden side boundary prompt to be a user message");
|
||||
};
|
||||
assert_eq!(role, "user");
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
panic!("expected hidden side boundary prompt text");
|
||||
};
|
||||
assert!(text.contains("Side conversation boundary."));
|
||||
assert!(text.contains("Everything before this boundary is inherited history"));
|
||||
assert!(text.contains("It is not your current task."));
|
||||
assert!(text.contains("Only messages submitted after this boundary are active"));
|
||||
assert!(text.contains("Do not continue, execute, or complete"));
|
||||
assert!(text.contains("separate from the main thread"));
|
||||
assert!(
|
||||
text.contains("External tools may be available according to this thread's current")
|
||||
);
|
||||
assert!(text.contains("Any tool calls or outputs visible before this boundary happened"));
|
||||
assert!(text.contains("Do not modify files"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_start_error_message_explains_missing_first_prompt() {
|
||||
let err = color_eyre::eyre::eyre!(
|
||||
"thread/fork failed during TUI bootstrap: thread/fork failed: no rollout found for thread id 019da1a1-bed9-7a43-88a2-b49d43915021"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
App::side_start_error_message(&err),
|
||||
"'/side' is unavailable until the current conversation has started. Send a message first, then try /side again."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_start_error_message_uses_generic_start_wording() {
|
||||
let err = color_eyre::eyre::eyre!("transport disconnected");
|
||||
|
||||
assert_eq!(
|
||||
App::side_start_error_message(&err),
|
||||
"Failed to start side conversation: transport disconnected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum SideParentStatusChange {
|
||||
Set(SideParentStatus),
|
||||
|
||||
@@ -324,3 +324,31 @@ pub(super) fn normalize_harness_overrides_for_cwd(
|
||||
overrides.additional_writable_roots = normalized;
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::PathBufExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let base_cwd = temp_dir.path().join("base").abs();
|
||||
std::fs::create_dir_all(base_cwd.as_path())?;
|
||||
|
||||
let overrides = ConfigOverrides {
|
||||
additional_writable_roots: vec![PathBuf::from("rel")],
|
||||
..Default::default()
|
||||
};
|
||||
let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?;
|
||||
|
||||
assert_eq!(
|
||||
normalized.additional_writable_roots,
|
||||
vec![base_cwd.join("rel").into_path_buf()]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Shared App fixtures for app submodule unit tests.
|
||||
//!
|
||||
//! This module keeps heavyweight `App` construction and config-inspection helpers available to
|
||||
//! focused sibling test modules without making `app/tests.rs` the only practical place to test
|
||||
//! app-owned behavior.
|
||||
|
||||
use super::*;
|
||||
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
||||
|
||||
pub(super) async fn make_test_app() -> App {
|
||||
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
||||
let config = chat_widget.config_ref().clone();
|
||||
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
|
||||
let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
|
||||
let session_telemetry = test_session_telemetry(&config, model.as_str());
|
||||
|
||||
App {
|
||||
model_catalog: chat_widget.model_catalog(),
|
||||
session_telemetry,
|
||||
app_event_tx,
|
||||
chat_widget,
|
||||
config,
|
||||
active_profile: None,
|
||||
cli_kv_overrides: Vec::new(),
|
||||
harness_overrides: ConfigOverrides::default(),
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
enhanced_keys_supported: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
feedback_audience: FeedbackAudience::External,
|
||||
environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)),
|
||||
remote_app_server_url: None,
|
||||
remote_app_server_auth_token: None,
|
||||
pending_update_action: None,
|
||||
pending_shutdown_exit_thread_id: None,
|
||||
windows_sandbox: WindowsSandboxState::default(),
|
||||
thread_event_channels: HashMap::new(),
|
||||
thread_event_listener_tasks: HashMap::new(),
|
||||
agent_navigation: AgentNavigationState::default(),
|
||||
side_threads: HashMap::new(),
|
||||
active_thread_id: None,
|
||||
active_thread_rx: None,
|
||||
primary_thread_id: None,
|
||||
last_subagent_backfill_attempt: None,
|
||||
primary_session_configured: None,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry {
|
||||
let model_info = crate::legacy_core::test_support::construct_model_info_offline(model, config);
|
||||
SessionTelemetry::new(
|
||||
ThreadId::new(),
|
||||
model,
|
||||
model_info.slug.as_str(),
|
||||
/*account_id*/ None,
|
||||
/*account_email*/ None,
|
||||
/*auth_mode*/ None,
|
||||
"test_originator".to_string(),
|
||||
/*log_user_prompts*/ false,
|
||||
"test".to_string(),
|
||||
SessionSource::Cli,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn app_enabled_in_effective_config(config: &Config, app_id: &str) -> Option<bool> {
|
||||
config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.as_table()
|
||||
.and_then(|table| table.get("apps"))
|
||||
.and_then(TomlValue::as_table)
|
||||
.and_then(|apps| apps.get(app_id))
|
||||
.and_then(TomlValue::as_table)
|
||||
.and_then(|app| app.get("enabled"))
|
||||
.and_then(TomlValue::as_bool)
|
||||
}
|
||||
@@ -28,16 +28,6 @@ use codex_app_server_protocol::AdditionalPermissionProfile;
|
||||
use codex_app_server_protocol::AgentMessageDeltaNotification;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::HookCompletedNotification;
|
||||
use codex_app_server_protocol::HookEventName as AppServerHookEventName;
|
||||
use codex_app_server_protocol::HookExecutionMode as AppServerHookExecutionMode;
|
||||
use codex_app_server_protocol::HookHandlerType as AppServerHookHandlerType;
|
||||
use codex_app_server_protocol::HookOutputEntry as AppServerHookOutputEntry;
|
||||
use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind;
|
||||
use codex_app_server_protocol::HookRunStatus as AppServerHookRunStatus;
|
||||
use codex_app_server_protocol::HookRunSummary as AppServerHookRunSummary;
|
||||
use codex_app_server_protocol::HookScope as AppServerHookScope;
|
||||
use codex_app_server_protocol::HookStartedNotification;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::McpServerStartupState;
|
||||
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
|
||||
@@ -47,7 +37,6 @@ use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyA
|
||||
use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction;
|
||||
use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind;
|
||||
use codex_app_server_protocol::PermissionsRequestApprovalParams;
|
||||
use codex_app_server_protocol::PluginMarketplaceEntry;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
@@ -72,14 +61,12 @@ use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::mcp::Tool;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::McpAuthStatus;
|
||||
use codex_protocol::protocol::NetworkApprovalContext;
|
||||
use codex_protocol::protocol::NetworkApprovalProtocol;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
@@ -114,109 +101,6 @@ 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 normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let base_cwd = temp_dir.path().join("base").abs();
|
||||
std::fs::create_dir_all(base_cwd.as_path())?;
|
||||
|
||||
let overrides = ConfigOverrides {
|
||||
additional_writable_roots: vec![PathBuf::from("rel")],
|
||||
..Default::default()
|
||||
};
|
||||
let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?;
|
||||
|
||||
assert_eq!(
|
||||
normalized.additional_writable_roots,
|
||||
vec![base_cwd.join("rel").into_path_buf()]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_mcp_inventory_result_clears_committed_loading_cell() {
|
||||
let mut app = make_test_app().await;
|
||||
@@ -1438,59 +1322,6 @@ async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_thread_read_error_detection_matches_not_loaded_errors() {
|
||||
let err = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123"
|
||||
);
|
||||
|
||||
assert!(App::is_terminal_thread_read_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_thread_read_error_detection_ignores_transient_failures() {
|
||||
let err = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read transport error: broken pipe"
|
||||
);
|
||||
|
||||
assert!(!App::is_terminal_thread_read_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_state_for_thread_read_error_preserves_live_state_without_cache_on_transient_error() {
|
||||
let err = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read transport error: broken pipe"
|
||||
);
|
||||
|
||||
assert!(!App::closed_state_for_thread_read_error(
|
||||
&err, /*existing_is_closed*/ None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_state_for_thread_read_error_marks_terminal_uncached_threads_closed() {
|
||||
let err = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123"
|
||||
);
|
||||
|
||||
assert!(App::closed_state_for_thread_read_error(
|
||||
&err, /*existing_is_closed*/ None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_turns_fallback_detection_handles_unmaterialized_and_ephemeral_threads() {
|
||||
let unmaterialized = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read failed: thread thr_123 is not materialized yet; includeTurns is unavailable before first user message"
|
||||
);
|
||||
let ephemeral = color_eyre::eyre::eyre!(
|
||||
"thread/read failed during TUI session lookup: thread/read failed: ephemeral threads do not support includeTurns"
|
||||
);
|
||||
|
||||
assert!(App::can_fallback_from_include_turns_error(&unmaterialized));
|
||||
assert!(App::can_fallback_from_include_turns_error(&ephemeral));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_agent_picker_marks_loaded_threads_open() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
@@ -3080,57 +2911,6 @@ async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() {
|
||||
assert!(app.transcript_cells.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_boundary_prompt_marks_inherited_history_reference_only() {
|
||||
let item = App::side_boundary_prompt_item();
|
||||
let codex_protocol::models::ResponseItem::Message { role, content, .. } = item else {
|
||||
panic!("expected hidden side boundary prompt to be a user message");
|
||||
};
|
||||
assert_eq!(role, "user");
|
||||
let [codex_protocol::models::ContentItem::InputText { text }] = content.as_slice() else {
|
||||
panic!("expected hidden side boundary prompt text");
|
||||
};
|
||||
assert!(text.contains("Side conversation boundary."));
|
||||
assert!(text.contains("Everything before this boundary is inherited history"));
|
||||
assert!(text.contains("It is not your current task."));
|
||||
assert!(text.contains("Only messages submitted after this boundary are active"));
|
||||
assert!(text.contains("Do not continue, execute, or complete"));
|
||||
assert!(text.contains("separate from the main thread"));
|
||||
assert!(text.contains("External tools may be available according to this thread's current"));
|
||||
assert!(text.contains("Any tool calls or outputs visible before this boundary happened"));
|
||||
assert!(text.contains("Do not modify files"));
|
||||
}
|
||||
|
||||
#[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,
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn side_start_block_message_tracks_open_side_conversation() {
|
||||
let mut app = make_test_app().await;
|
||||
@@ -3281,28 +3061,6 @@ async fn side_parent_status_prioritizes_input_over_approval() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_start_error_message_explains_missing_first_prompt() {
|
||||
let err = color_eyre::eyre::eyre!(
|
||||
"thread/fork failed during TUI bootstrap: thread/fork failed: no rollout found for thread id 019da1a1-bed9-7a43-88a2-b49d43915021"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
App::side_start_error_message(&err),
|
||||
"'/side' is unavailable until the current conversation has started. Send a message first, then try /side again."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_start_error_message_uses_generic_start_wording() {
|
||||
let err = color_eyre::eyre::eyre!("transport disconnected");
|
||||
|
||||
assert_eq!(
|
||||
App::side_start_error_message(&err),
|
||||
"Failed to start side conversation: transport disconnected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn side_thread_snapshot_hides_forked_parent_transcript() {
|
||||
let parent_thread_id = ThreadId::new();
|
||||
@@ -3991,61 +3749,6 @@ fn token_usage_notification(
|
||||
})
|
||||
}
|
||||
|
||||
fn hook_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification {
|
||||
ServerNotification::HookStarted(HookStartedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: Some(turn_id.to_string()),
|
||||
run: AppServerHookRunSummary {
|
||||
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
|
||||
event_name: AppServerHookEventName::UserPromptSubmit,
|
||||
handler_type: AppServerHookHandlerType::Command,
|
||||
execution_mode: AppServerHookExecutionMode::Sync,
|
||||
scope: AppServerHookScope::Turn,
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_app_server_protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
status: AppServerHookRunStatus::Running,
|
||||
status_message: Some("checking go-workflow input policy".to_string()),
|
||||
started_at: 1,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
entries: Vec::new(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn hook_completed_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification {
|
||||
ServerNotification::HookCompleted(HookCompletedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: Some(turn_id.to_string()),
|
||||
run: AppServerHookRunSummary {
|
||||
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
|
||||
event_name: AppServerHookEventName::UserPromptSubmit,
|
||||
handler_type: AppServerHookHandlerType::Command,
|
||||
execution_mode: AppServerHookExecutionMode::Sync,
|
||||
scope: AppServerHookScope::Turn,
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_app_server_protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
status: AppServerHookRunStatus::Stopped,
|
||||
status_message: Some("checking go-workflow input policy".to_string()),
|
||||
started_at: 1,
|
||||
completed_at: Some(11),
|
||||
duration_ms: Some(10),
|
||||
entries: vec![
|
||||
AppServerHookOutputEntry {
|
||||
kind: AppServerHookOutputEntryKind::Warning,
|
||||
text: "go-workflow must start from PlanMode".to_string(),
|
||||
},
|
||||
AppServerHookOutputEntry {
|
||||
kind: AppServerHookOutputEntryKind::Stop,
|
||||
text: "prompt blocked".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_message_delta_notification(
|
||||
thread_id: ThreadId,
|
||||
turn_id: &str,
|
||||
@@ -4098,162 +3801,6 @@ fn request_user_input_request(thread_id: ThreadId, turn_id: &str, item_id: &str)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_tracks_active_turn_lifecycle() {
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 8);
|
||||
assert_eq!(store.active_turn_id(), None);
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
store.push_notification(turn_started_notification(thread_id, "turn-1"));
|
||||
assert_eq!(store.active_turn_id(), Some("turn-1"));
|
||||
|
||||
store.push_notification(turn_completed_notification(
|
||||
thread_id,
|
||||
"turn-2",
|
||||
TurnStatus::Completed,
|
||||
));
|
||||
assert_eq!(store.active_turn_id(), Some("turn-1"));
|
||||
|
||||
store.push_notification(turn_completed_notification(
|
||||
thread_id,
|
||||
"turn-1",
|
||||
TurnStatus::Interrupted,
|
||||
));
|
||||
assert_eq!(store.active_turn_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_restores_active_turn_from_snapshot_turns() {
|
||||
let thread_id = ThreadId::new();
|
||||
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
||||
let turns = vec![
|
||||
test_turn("turn-1", TurnStatus::Completed, Vec::new()),
|
||||
test_turn("turn-2", TurnStatus::InProgress, Vec::new()),
|
||||
];
|
||||
|
||||
let store =
|
||||
ThreadEventStore::new_with_session(/*capacity*/ 8, session.clone(), turns.clone());
|
||||
assert_eq!(store.active_turn_id(), Some("turn-2"));
|
||||
|
||||
let mut refreshed_store = ThreadEventStore::new(/*capacity*/ 8);
|
||||
refreshed_store.set_session(session, turns);
|
||||
assert_eq!(refreshed_store.active_turn_id(), Some("turn-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_clear_active_turn_id_resets_cached_turn() {
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 8);
|
||||
let thread_id = ThreadId::new();
|
||||
store.push_notification(turn_started_notification(thread_id, "turn-1"));
|
||||
|
||||
store.clear_active_turn_id();
|
||||
|
||||
assert_eq!(store.active_turn_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_rebase_preserves_resolved_request_state() {
|
||||
let thread_id = ThreadId::new();
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 8);
|
||||
store.push_request(exec_approval_request(
|
||||
thread_id,
|
||||
"turn-approval",
|
||||
"call-approval",
|
||||
/*approval_id*/ None,
|
||||
));
|
||||
store.push_notification(ServerNotification::ServerRequestResolved(
|
||||
codex_app_server_protocol::ServerRequestResolvedNotification {
|
||||
request_id: AppServerRequestId::Integer(1),
|
||||
thread_id: thread_id.to_string(),
|
||||
},
|
||||
));
|
||||
|
||||
store.rebase_buffer_after_session_refresh();
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert!(snapshot.events.is_empty());
|
||||
assert_eq!(store.has_pending_thread_approvals(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_rebase_preserves_hook_notifications() {
|
||||
let thread_id = ThreadId::new();
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 8);
|
||||
store.push_notification(hook_started_notification(thread_id, "turn-hook"));
|
||||
store.push_notification(hook_completed_notification(thread_id, "turn-hook"));
|
||||
|
||||
store.rebase_buffer_after_session_refresh();
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
let hook_notifications = snapshot
|
||||
.events
|
||||
.into_iter()
|
||||
.map(|event| match event {
|
||||
ThreadBufferedEvent::Notification(notification) => {
|
||||
serde_json::to_value(notification).expect("hook notification should serialize")
|
||||
}
|
||||
other => panic!("expected buffered hook notification, saw: {other:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
hook_notifications,
|
||||
vec![
|
||||
serde_json::to_value(hook_started_notification(thread_id, "turn-hook"))
|
||||
.expect("hook notification should serialize"),
|
||||
serde_json::to_value(hook_completed_notification(thread_id, "turn-hook"))
|
||||
.expect("hook notification should serialize"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feedback_submission_without_thread_emits_error_history_cell() {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
@@ -4383,19 +3930,6 @@ fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry {
|
||||
)
|
||||
}
|
||||
|
||||
fn app_enabled_in_effective_config(config: &Config, app_id: &str) -> Option<bool> {
|
||||
config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.as_table()
|
||||
.and_then(|table| table.get("apps"))
|
||||
.and_then(TomlValue::as_table)
|
||||
.and_then(|apps| apps.get(app_id))
|
||||
.and_then(TomlValue::as_table)
|
||||
.and_then(|app| app.get("enabled"))
|
||||
.and_then(TomlValue::as_bool)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_turn_not_steerable_turn_error_extracts_structured_server_error() {
|
||||
let turn_error = AppServerTurnError {
|
||||
@@ -4457,166 +3991,6 @@ fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() {
|
||||
);
|
||||
}
|
||||
|
||||
#[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(¤t_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(¤t_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")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_session_config_uses_current_service_tier() {
|
||||
let mut app = make_test_app().await;
|
||||
|
||||
@@ -264,3 +264,277 @@ impl ThreadEventChannel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::PathBufExt;
|
||||
use crate::test_support::test_path_buf;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
|
||||
use codex_app_server_protocol::HookCompletedNotification;
|
||||
use codex_app_server_protocol::HookEventName as AppServerHookEventName;
|
||||
use codex_app_server_protocol::HookExecutionMode as AppServerHookExecutionMode;
|
||||
use codex_app_server_protocol::HookHandlerType as AppServerHookHandlerType;
|
||||
use codex_app_server_protocol::HookOutputEntry as AppServerHookOutputEntry;
|
||||
use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind;
|
||||
use codex_app_server_protocol::HookRunStatus as AppServerHookRunStatus;
|
||||
use codex_app_server_protocol::HookRunSummary as AppServerHookRunSummary;
|
||||
use codex_app_server_protocol::HookScope as AppServerHookScope;
|
||||
use codex_app_server_protocol::HookStartedNotification;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState {
|
||||
ThreadSessionState {
|
||||
thread_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: 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: cwd.abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_turn(turn_id: &str, status: TurnStatus, items: Vec<ThreadItem>) -> Turn {
|
||||
Turn {
|
||||
id: turn_id.to_string(),
|
||||
items,
|
||||
status,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification {
|
||||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn: Turn {
|
||||
started_at: Some(0),
|
||||
..test_turn(turn_id, TurnStatus::InProgress, Vec::new())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn turn_completed_notification(
|
||||
thread_id: ThreadId,
|
||||
turn_id: &str,
|
||||
status: TurnStatus,
|
||||
) -> ServerNotification {
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn: Turn {
|
||||
completed_at: Some(0),
|
||||
duration_ms: Some(1),
|
||||
..test_turn(turn_id, status, Vec::new())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn hook_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification {
|
||||
ServerNotification::HookStarted(HookStartedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: Some(turn_id.to_string()),
|
||||
run: AppServerHookRunSummary {
|
||||
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
|
||||
event_name: AppServerHookEventName::UserPromptSubmit,
|
||||
handler_type: AppServerHookHandlerType::Command,
|
||||
execution_mode: AppServerHookExecutionMode::Sync,
|
||||
scope: AppServerHookScope::Turn,
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_app_server_protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
status: AppServerHookRunStatus::Running,
|
||||
status_message: Some("checking go-workflow input policy".to_string()),
|
||||
started_at: 1,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
entries: Vec::new(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn hook_completed_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification {
|
||||
ServerNotification::HookCompleted(HookCompletedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: Some(turn_id.to_string()),
|
||||
run: AppServerHookRunSummary {
|
||||
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
|
||||
event_name: AppServerHookEventName::UserPromptSubmit,
|
||||
handler_type: AppServerHookHandlerType::Command,
|
||||
execution_mode: AppServerHookExecutionMode::Sync,
|
||||
scope: AppServerHookScope::Turn,
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
||||
source: codex_app_server_protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
status: AppServerHookRunStatus::Stopped,
|
||||
status_message: Some("checking go-workflow input policy".to_string()),
|
||||
started_at: 1,
|
||||
completed_at: Some(11),
|
||||
duration_ms: Some(10),
|
||||
entries: vec![
|
||||
AppServerHookOutputEntry {
|
||||
kind: AppServerHookOutputEntryKind::Warning,
|
||||
text: "go-workflow must start from PlanMode".to_string(),
|
||||
},
|
||||
AppServerHookOutputEntry {
|
||||
kind: AppServerHookOutputEntryKind::Stop,
|
||||
text: "prompt blocked".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn exec_approval_request(
|
||||
thread_id: ThreadId,
|
||||
turn_id: &str,
|
||||
item_id: &str,
|
||||
approval_id: Option<&str>,
|
||||
) -> ServerRequest {
|
||||
ServerRequest::CommandExecutionRequestApproval {
|
||||
request_id: AppServerRequestId::Integer(1),
|
||||
params: CommandExecutionRequestApprovalParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
approval_id: approval_id.map(str::to_string),
|
||||
reason: Some("needs approval".to_string()),
|
||||
network_approval_context: None,
|
||||
command: Some("echo hello".to_string()),
|
||||
cwd: Some(test_path_buf("/tmp/project").abs()),
|
||||
command_actions: None,
|
||||
additional_permissions: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
available_decisions: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_tracks_active_turn_lifecycle() {
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 8);
|
||||
assert_eq!(store.active_turn_id(), None);
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
store.push_notification(turn_started_notification(thread_id, "turn-1"));
|
||||
assert_eq!(store.active_turn_id(), Some("turn-1"));
|
||||
|
||||
store.push_notification(turn_completed_notification(
|
||||
thread_id,
|
||||
"turn-2",
|
||||
TurnStatus::Completed,
|
||||
));
|
||||
assert_eq!(store.active_turn_id(), Some("turn-1"));
|
||||
|
||||
store.push_notification(turn_completed_notification(
|
||||
thread_id,
|
||||
"turn-1",
|
||||
TurnStatus::Interrupted,
|
||||
));
|
||||
assert_eq!(store.active_turn_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_restores_active_turn_from_snapshot_turns() {
|
||||
let thread_id = ThreadId::new();
|
||||
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
||||
let turns = vec![
|
||||
test_turn("turn-1", TurnStatus::Completed, Vec::new()),
|
||||
test_turn("turn-2", TurnStatus::InProgress, Vec::new()),
|
||||
];
|
||||
|
||||
let store =
|
||||
ThreadEventStore::new_with_session(/*capacity*/ 8, session.clone(), turns.clone());
|
||||
assert_eq!(store.active_turn_id(), Some("turn-2"));
|
||||
|
||||
let mut refreshed_store = ThreadEventStore::new(/*capacity*/ 8);
|
||||
refreshed_store.set_session(session, turns);
|
||||
assert_eq!(refreshed_store.active_turn_id(), Some("turn-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_clear_active_turn_id_resets_cached_turn() {
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 8);
|
||||
let thread_id = ThreadId::new();
|
||||
store.push_notification(turn_started_notification(thread_id, "turn-1"));
|
||||
|
||||
store.clear_active_turn_id();
|
||||
|
||||
assert_eq!(store.active_turn_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_rebase_preserves_resolved_request_state() {
|
||||
let thread_id = ThreadId::new();
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 8);
|
||||
store.push_request(exec_approval_request(
|
||||
thread_id,
|
||||
"turn-approval",
|
||||
"call-approval",
|
||||
/*approval_id*/ None,
|
||||
));
|
||||
store.push_notification(ServerNotification::ServerRequestResolved(
|
||||
codex_app_server_protocol::ServerRequestResolvedNotification {
|
||||
request_id: AppServerRequestId::Integer(1),
|
||||
thread_id: thread_id.to_string(),
|
||||
},
|
||||
));
|
||||
|
||||
store.rebase_buffer_after_session_refresh();
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert!(snapshot.events.is_empty());
|
||||
assert_eq!(store.has_pending_thread_approvals(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_rebase_preserves_hook_notifications() {
|
||||
let thread_id = ThreadId::new();
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 8);
|
||||
store.push_notification(hook_started_notification(thread_id, "turn-hook"));
|
||||
store.push_notification(hook_completed_notification(thread_id, "turn-hook"));
|
||||
|
||||
store.rebase_buffer_after_session_refresh();
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
let hook_notifications = snapshot
|
||||
.events
|
||||
.into_iter()
|
||||
.map(|event| match event {
|
||||
ThreadBufferedEvent::Notification(notification) => {
|
||||
serde_json::to_value(notification).expect("hook notification should serialize")
|
||||
}
|
||||
other => panic!("expected buffered hook notification, saw: {other:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
hook_notifications,
|
||||
vec![
|
||||
serde_json::to_value(hook_started_notification(thread_id, "turn-hook"))
|
||||
.expect("hook notification should serialize"),
|
||||
serde_json::to_value(hook_completed_notification(thread_id, "turn-hook"))
|
||||
.expect("hook notification should serialize"),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user