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:
Eric Traut
2026-04-21 10:16:51 -07:00
committed by GitHub
Unverified
parent 10e1659d4f
commit 4ed722ab8d
10 changed files with 855 additions and 628 deletions
+2 -2
View File
@@ -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;
+145
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)));
}
}
+58
View File
@@ -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));
}
}
+51
View File
@@ -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),
+28
View File
@@ -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(())
}
}
+89
View File
@@ -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)
}
-626
View File
@@ -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(&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")
);
}
#[tokio::test]
async fn fresh_session_config_uses_current_service_tier() {
let mut app = make_test_app().await;
+274
View File
@@ -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"),
]
);
}
}