diff --git a/.github/scripts/verify_tui_core_boundary.py b/.github/scripts/verify_tui_core_boundary.py index e66afeca92..2c2c25672c 100644 --- a/.github/scripts/verify_tui_core_boundary.py +++ b/.github/scripts/verify_tui_core_boundary.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -"""Verify codex-tui does not depend on or import codex-core directly.""" +"""Verify codex-tui stays behind the app-server/core boundary.""" from __future__ import annotations @@ -14,10 +14,22 @@ ROOT = Path(__file__).resolve().parents[2] TUI_ROOT = ROOT / "codex-rs" / "tui" TUI_MANIFEST = TUI_ROOT / "Cargo.toml" FORBIDDEN_PACKAGE = "codex-core" -FORBIDDEN_SOURCE_PATTERNS = ( - re.compile(r"\bcodex_core::"), - re.compile(r"\buse\s+codex_core\b"), - re.compile(r"\bextern\s+crate\s+codex_core\b"), +FORBIDDEN_SOURCE_RULES = ( + ( + "imports `codex_core`", + ( + re.compile(r"\bcodex_core::"), + re.compile(r"\buse\s+codex_core\b"), + re.compile(r"\bextern\s+crate\s+codex_core\b"), + ), + ), + ( + "references `codex_protocol::protocol`", + ( + re.compile(r"\bcodex_protocol\s*::\s*protocol\b"), + re.compile(r"\bcodex_protocol\s*::\s*\{[^}\n]*\bprotocol\b"), + ), + ), ) @@ -29,10 +41,11 @@ def main() -> int: if not failures: return 0 - print("codex-tui must not depend on or import codex-core directly.") + print("codex-tui must stay behind the app-server/core boundary.") print( - "Use the app-server protocol/client boundary instead; temporary embedded " - "startup gaps belong behind codex_app_server_client::legacy_core." + "Use app-server protocol types at the TUI boundary; temporary embedded " + "startup gaps belong behind codex_app_server_client::legacy_core, and " + "core protocol references should remain outside codex-tui." ) print() for failure in failures: @@ -76,8 +89,9 @@ def source_failures() -> list[str]: for path in sorted(TUI_ROOT.glob("**/*.rs")): text = path.read_text() for line_number, line in enumerate(text.splitlines(), start=1): - if any(pattern.search(line) for pattern in FORBIDDEN_SOURCE_PATTERNS): - failures.append(f"{relative_path(path)}:{line_number} imports `codex_core`") + for message, patterns in FORBIDDEN_SOURCE_RULES: + if any(pattern.search(line) for pattern in patterns): + failures.append(f"{relative_path(path)}:{line_number} {message}") return failures diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index ab33d9f948..8cdc5dc9ac 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -31,6 +31,7 @@ use codex_state::state_db_path; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use codex_tui::ExitReason; +use codex_tui::FinalOutput; use codex_tui::UpdateAction; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; @@ -534,10 +535,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec TuiCli { diff --git a/codex-rs/tui/src/additional_dirs.rs b/codex-rs/tui/src/additional_dirs.rs index 4bf66a3206..b503b13112 100644 --- a/codex-rs/tui/src/additional_dirs.rs +++ b/codex-rs/tui/src/additional_dirs.rs @@ -46,13 +46,14 @@ fn format_warning(additional_dirs: &[PathBuf]) -> String { #[cfg(test)] mod tests { use super::add_dir_warning_message; - use codex_protocol::models::ManagedFileSystemPermissions; + use codex_app_server_protocol::FileSystemAccessMode; + use codex_app_server_protocol::FileSystemPath; + use codex_app_server_protocol::FileSystemSandboxEntry; + use codex_app_server_protocol::FileSystemSpecialPath; + use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; + use codex_app_server_protocol::PermissionProfileFileSystemPermissions; + use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_protocol::models::PermissionProfile; - use codex_protocol::permissions::FileSystemAccessMode; - use codex_protocol::permissions::FileSystemPath; - use codex_protocol::permissions::FileSystemSandboxEntry; - use codex_protocol::permissions::FileSystemSpecialPath; - use codex_protocol::protocol::NetworkSandboxPolicy; use pretty_assertions::assert_eq; use std::path::Path; use std::path::PathBuf; @@ -79,9 +80,10 @@ mod tests { #[test] fn returns_none_for_external_sandbox() { - let profile = PermissionProfile::External { - network: NetworkSandboxPolicy::Enabled, - }; + let profile: PermissionProfile = AppServerPermissionProfile::External { + network: PermissionProfileNetworkPermissions { enabled: true }, + } + .into(); let dirs = vec![PathBuf::from("/tmp/example")]; assert_eq!( add_dir_warning_message(&dirs, &profile, Path::new("/tmp/project")), @@ -103,8 +105,9 @@ mod tests { #[test] fn warns_when_profile_can_write_elsewhere_but_not_cwd() { - let profile = PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Restricted { + let profile: PermissionProfile = AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -121,8 +124,8 @@ mod tests { ], glob_scan_max_depth: None, }, - network: NetworkSandboxPolicy::Restricted, - }; + } + .into(); let dirs = vec![PathBuf::from("/tmp/extra")]; assert_eq!( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1d138f9da4..0b1efa16af 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5,20 +5,18 @@ use crate::app_backtrack::BacktrackState; use crate::app_command::AppCommand; -use crate::app_command::AppCommandView; use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event::FeedbackCategory; +use crate::app_event::HistoryLookupResponse; use crate::app_event::RateLimitRefreshOrigin; use crate::app_event::RealtimeAudioDeviceKind; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; -use crate::app_server_approval_conversions::network_approval_context_to_core; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; -use crate::app_server_session::ThreadSessionState; -use crate::app_server_session::app_server_rate_limit_snapshots_to_core; +use crate::app_server_session::app_server_rate_limit_snapshots; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::McpServerElicitationFormRequest; @@ -62,17 +60,19 @@ use crate::multi_agents::format_agent_picker_item_name; use crate::multi_agents::next_agent_shortcut_matches; use crate::multi_agents::previous_agent_shortcut_matches; use crate::pager_overlay::Overlay; -use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; use crate::resume_picker::SessionTarget; +use crate::session_state::ThreadSessionState; #[cfg(test)] use crate::test_support::PathBufExt; #[cfg(test)] use crate::test_support::test_path_buf; #[cfg(test)] use crate::test_support::test_path_display; +use crate::token_usage::FinalOutput; +use crate::token_usage::TokenUsage; use crate::transcript_reflow::TranscriptReflowState; use crate::tui; use crate::tui::TuiEvent; @@ -82,6 +82,7 @@ use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; @@ -92,6 +93,8 @@ use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; +#[cfg(test)] +use codex_app_server_protocol::McpAuthStatus; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::MergeStrategy; @@ -103,10 +106,12 @@ use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SkillErrorInfo; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadItem; @@ -127,7 +132,6 @@ use codex_models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT use codex_models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; -use codex_protocol::approvals::ExecApprovalRequestEvent; use codex_protocol::config_types::Personality; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; @@ -136,19 +140,6 @@ use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::protocol::AskForApproval; -#[cfg(target_os = "windows")] -use codex_protocol::protocol::FileSystemSandboxKind; -use codex_protocol::protocol::FinalOutput; -use codex_protocol::protocol::GetHistoryEntryResponseEvent; -use codex_protocol::protocol::ListSkillsResponseEvent; -#[cfg(test)] -use codex_protocol::protocol::McpAuthStatus; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::RateLimitSnapshot; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SkillErrorInfo; -use codex_protocol::protocol::TokenUsage; use codex_terminal_detection::user_agent; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; @@ -184,7 +175,8 @@ use tokio::task::JoinHandle; use toml::Value as TomlValue; use uuid::Uuid; mod agent_navigation; -mod app_server_adapter; +mod app_server_event_targets; +mod app_server_events; pub(crate) mod app_server_requests; mod background_requests; mod config_persistence; @@ -237,35 +229,6 @@ fn app_server_request_id_to_mcp_request_id( } } -fn command_execution_decision_to_review_decision( - decision: codex_app_server_protocol::CommandExecutionApprovalDecision, -) -> codex_protocol::protocol::ReviewDecision { - match decision { - codex_app_server_protocol::CommandExecutionApprovalDecision::Accept => { - codex_protocol::protocol::ReviewDecision::Approved - } - codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession => { - codex_protocol::protocol::ReviewDecision::ApprovedForSession - } - codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { - execpolicy_amendment, - } => codex_protocol::protocol::ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: execpolicy_amendment.into_core(), - }, - codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { - network_policy_amendment, - } => codex_protocol::protocol::ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: network_policy_amendment.into_core(), - }, - codex_app_server_protocol::CommandExecutionApprovalDecision::Decline => { - codex_protocol::protocol::ReviewDecision::Denied - } - codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel => { - codex_protocol::protocol::ReviewDecision::Abort - } - } -} - /// Extracts `receiver_thread_ids` from collab agent tool-call notifications. /// /// Only `ItemStarted` and `ItemCompleted` notifications with a `CollabAgentToolCall` item carry @@ -291,19 +254,53 @@ fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[Str } fn default_exec_approval_decisions( - network_approval_context: Option<&codex_protocol::protocol::NetworkApprovalContext>, - proposed_execpolicy_amendment: Option<&codex_protocol::approvals::ExecPolicyAmendment>, + network_approval_context: Option<&codex_app_server_protocol::NetworkApprovalContext>, + proposed_execpolicy_amendment: Option<&codex_app_server_protocol::ExecPolicyAmendment>, proposed_network_policy_amendments: Option< - &[codex_protocol::approvals::NetworkPolicyAmendment], + &[codex_app_server_protocol::NetworkPolicyAmendment], >, - additional_permissions: Option<&codex_protocol::models::AdditionalPermissionProfile>, -) -> Vec { - ExecApprovalRequestEvent::default_available_decisions( - network_approval_context, - proposed_execpolicy_amendment, - proposed_network_policy_amendments, - additional_permissions, - ) + additional_permissions: Option<&codex_app_server_protocol::AdditionalPermissionProfile>, +) -> Vec { + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::NetworkPolicyRuleAction; + + if network_approval_context.is_some() { + let mut decisions = vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptForSession, + ]; + if let Some(amendment) = proposed_network_policy_amendments.and_then(|amendments| { + amendments + .iter() + .find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow) + }) { + decisions.push( + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment: amendment.clone(), + }, + ); + } + decisions.push(CommandExecutionApprovalDecision::Cancel); + return decisions; + } + + if additional_permissions.is_some() { + return vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ]; + } + + let mut decisions = vec![CommandExecutionApprovalDecision::Accept]; + if let Some(execpolicy_amendment) = proposed_execpolicy_amendment { + decisions.push( + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment: execpolicy_amendment.clone(), + }, + ); + } + decisions.push(CommandExecutionApprovalDecision::Cancel); + decisions } #[derive(Clone, Debug, PartialEq, Eq)] @@ -417,86 +414,15 @@ fn rollout_path_is_resumable(rollout_path: &Path) -> bool { std::fs::metadata(rollout_path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) } -fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec { +fn errors_for_cwd(cwd: &Path, response: &SkillsListResponse) -> Vec { response - .skills + .data .iter() .find(|entry| entry.cwd.as_path() == cwd) .map(|entry| entry.errors.clone()) .unwrap_or_default() } -fn list_skills_response_to_core(response: SkillsListResponse) -> ListSkillsResponseEvent { - ListSkillsResponseEvent { - skills: response - .data - .into_iter() - .map(|entry| codex_protocol::protocol::SkillsListEntry { - cwd: entry.cwd, - skills: entry - .skills - .into_iter() - .map(|skill| codex_protocol::protocol::SkillMetadata { - name: skill.name, - description: skill.description, - short_description: skill.short_description, - interface: skill.interface.map(|interface| { - codex_protocol::protocol::SkillInterface { - display_name: interface.display_name, - short_description: interface.short_description, - icon_small: interface.icon_small, - icon_large: interface.icon_large, - brand_color: interface.brand_color, - default_prompt: interface.default_prompt, - } - }), - dependencies: skill.dependencies.map(|dependencies| { - codex_protocol::protocol::SkillDependencies { - tools: dependencies - .tools - .into_iter() - .map(|tool| codex_protocol::protocol::SkillToolDependency { - r#type: tool.r#type, - value: tool.value, - description: tool.description, - transport: tool.transport, - command: tool.command, - url: tool.url, - }) - .collect(), - } - }), - path: skill.path, - scope: match skill.scope { - codex_app_server_protocol::SkillScope::User => { - codex_protocol::protocol::SkillScope::User - } - codex_app_server_protocol::SkillScope::Repo => { - codex_protocol::protocol::SkillScope::Repo - } - codex_app_server_protocol::SkillScope::System => { - codex_protocol::protocol::SkillScope::System - } - codex_app_server_protocol::SkillScope::Admin => { - codex_protocol::protocol::SkillScope::Admin - } - }, - enabled: skill.enabled, - }) - .collect(), - errors: entry - .errors - .into_iter() - .map(|error| codex_protocol::protocol::SkillErrorInfo { - path: error.path, - message: error.message, - }) - .collect(), - }) - .collect(), - } -} - #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: Option, @@ -767,7 +693,8 @@ impl App { codex_login::default_client::originator().value, config.otel.log_user_prompt, user_agent(), - SessionSource::Cli, + serde_json::from_value(serde_json::json!("cli")) + .unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")), ); if config .tui_status_line diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs deleted file mode 100644 index 0a1d01aec5..0000000000 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ /dev/null @@ -1,1714 +0,0 @@ -/* -This module holds the temporary adapter layer between the TUI and the app -server during the hybrid migration period. - -For now, the TUI still owns its existing direct-core behavior, but startup -allocates a local in-process app server and drains its event stream. Keeping -the app-server-specific wiring here keeps that transitional logic out of the -main `app.rs` orchestration path. - -As more TUI flows move onto the app-server surface directly, this adapter -should shrink and eventually disappear. -*/ - -use super::App; -use crate::app_command::AppCommand; -use crate::app_event::AppEvent; -use crate::app_server_session::AppServerSession; -use crate::app_server_session::app_server_rate_limit_snapshot_to_core; -use crate::app_server_session::status_account_display_from_auth_mode; -#[cfg(test)] -use crate::exec_command::split_command_string; -use codex_app_server_client::AppServerEvent; -use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ServerRequest; -#[cfg(test)] -use codex_app_server_protocol::Thread; -#[cfg(test)] -use codex_app_server_protocol::ThreadItem; -#[cfg(test)] -use codex_app_server_protocol::Turn; -#[cfg(test)] -use codex_app_server_protocol::TurnStatus; -use codex_protocol::ThreadId; -#[cfg(test)] -use codex_protocol::config_types::ModeKind; -#[cfg(test)] -use codex_protocol::items::AgentMessageContent; -#[cfg(test)] -use codex_protocol::items::AgentMessageItem; -#[cfg(test)] -use codex_protocol::items::ContextCompactionItem; -#[cfg(test)] -use codex_protocol::items::ImageGenerationItem; -#[cfg(test)] -use codex_protocol::items::PlanItem; -#[cfg(test)] -use codex_protocol::items::ReasoningItem; -#[cfg(test)] -use codex_protocol::items::TurnItem; -#[cfg(test)] -use codex_protocol::items::UserMessageItem; -#[cfg(test)] -use codex_protocol::items::WebSearchItem; -#[cfg(test)] -use codex_protocol::protocol::AgentMessageDeltaEvent; -#[cfg(test)] -use codex_protocol::protocol::AgentReasoningDeltaEvent; -#[cfg(test)] -use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent; -#[cfg(test)] -use codex_protocol::protocol::ErrorEvent; -#[cfg(test)] -use codex_protocol::protocol::Event; -#[cfg(test)] -use codex_protocol::protocol::EventMsg; -#[cfg(test)] -use codex_protocol::protocol::ExecCommandBeginEvent; -#[cfg(test)] -use codex_protocol::protocol::ExecCommandEndEvent; -#[cfg(test)] -use codex_protocol::protocol::ExecCommandOutputDeltaEvent; -#[cfg(test)] -use codex_protocol::protocol::ExecCommandStatus; -#[cfg(test)] -use codex_protocol::protocol::ExecOutputStream; -#[cfg(test)] -use codex_protocol::protocol::ItemCompletedEvent; -#[cfg(test)] -use codex_protocol::protocol::ItemStartedEvent; -#[cfg(test)] -use codex_protocol::protocol::PlanDeltaEvent; -#[cfg(test)] -use codex_protocol::protocol::RealtimeConversationClosedEvent; -#[cfg(test)] -use codex_protocol::protocol::RealtimeConversationRealtimeEvent; -#[cfg(test)] -use codex_protocol::protocol::RealtimeConversationStartedEvent; -#[cfg(test)] -use codex_protocol::protocol::RealtimeEvent; -#[cfg(test)] -use codex_protocol::protocol::ThreadNameUpdatedEvent; -#[cfg(test)] -use codex_protocol::protocol::TokenCountEvent; -#[cfg(test)] -use codex_protocol::protocol::TokenUsage; -#[cfg(test)] -use codex_protocol::protocol::TokenUsageInfo; -#[cfg(test)] -use codex_protocol::protocol::TurnAbortReason; -#[cfg(test)] -use codex_protocol::protocol::TurnAbortedEvent; -#[cfg(test)] -use codex_protocol::protocol::TurnCompleteEvent; -#[cfg(test)] -use codex_protocol::protocol::TurnStartedEvent; -#[cfg(test)] -use std::time::Duration; - -impl App { - fn refresh_mcp_startup_expected_servers_from_config(&mut self) { - let enabled_config_mcp_servers: Vec = self - .chat_widget - .config_ref() - .mcp_servers - .get() - .iter() - .filter_map(|(name, server)| server.enabled.then_some(name.clone())) - .collect(); - self.chat_widget - .set_mcp_startup_expected_servers(enabled_config_mcp_servers); - } - - pub(super) async fn handle_app_server_event( - &mut self, - app_server_client: &AppServerSession, - event: AppServerEvent, - ) { - match event { - AppServerEvent::Lagged { skipped } => { - tracing::warn!( - skipped, - "app-server event consumer lagged; dropping ignored events" - ); - self.refresh_mcp_startup_expected_servers_from_config(); - self.chat_widget.finish_mcp_startup_after_lag(); - } - AppServerEvent::ServerNotification(notification) => { - self.handle_server_notification_event(app_server_client, notification) - .await; - } - AppServerEvent::ServerRequest(request) => { - self.handle_server_request_event(app_server_client, request) - .await; - } - AppServerEvent::Disconnected { message } => { - tracing::warn!("app-server event stream disconnected: {message}"); - self.chat_widget.add_error_message(message.clone()); - self.app_event_tx.send(AppEvent::FatalExitRequest(message)); - } - } - } - - async fn handle_server_notification_event( - &mut self, - app_server_client: &AppServerSession, - notification: ServerNotification, - ) { - match ¬ification { - ServerNotification::ServerRequestResolved(notification) => { - if let Some(request) = self - .pending_app_server_requests - .resolve_notification(¬ification.request_id) - { - self.chat_widget.dismiss_app_server_request(&request); - } - } - ServerNotification::McpServerStatusUpdated(_) => { - self.refresh_mcp_startup_expected_servers_from_config(); - } - ServerNotification::AccountRateLimitsUpdated(notification) => { - self.chat_widget.on_rate_limit_snapshot(Some( - app_server_rate_limit_snapshot_to_core(notification.rate_limits.clone()), - )); - return; - } - ServerNotification::AccountUpdated(notification) => { - self.chat_widget.update_account_state( - status_account_display_from_auth_mode( - notification.auth_mode, - notification.plan_type, - ), - notification.plan_type, - matches!( - notification.auth_mode, - Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) - ), - ); - return; - } - ServerNotification::ExternalAgentConfigImportCompleted(_) => { - let cwd = self.chat_widget.config_ref().cwd.to_path_buf(); - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after external agent config import" - ); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); - self.fetch_plugins_list(app_server_client, cwd); - return; - } - _ => {} - } - - match server_notification_thread_target(¬ification) { - ServerNotificationThreadTarget::Thread(thread_id) => { - let result = if self.primary_thread_id == Some(thread_id) - || self.primary_thread_id.is_none() - { - self.enqueue_primary_thread_notification(notification).await - } else { - self.enqueue_thread_notification(thread_id, notification) - .await - }; - - if let Err(err) = result { - tracing::warn!("failed to enqueue app-server notification: {err}"); - } - return; - } - ServerNotificationThreadTarget::InvalidThreadId(thread_id) => { - tracing::warn!( - thread_id, - "ignoring app-server notification with invalid thread_id" - ); - return; - } - ServerNotificationThreadTarget::Global => {} - } - - self.chat_widget - .handle_server_notification(notification, /*replay_kind*/ None); - } - - async fn handle_server_request_event( - &mut self, - app_server_client: &AppServerSession, - request: ServerRequest, - ) { - if let Some(unsupported) = self - .pending_app_server_requests - .note_server_request(&request) - { - tracing::warn!( - request_id = ?unsupported.request_id, - message = unsupported.message, - "rejecting unsupported app-server request" - ); - self.chat_widget - .add_error_message(unsupported.message.clone()); - if let Err(err) = self - .reject_app_server_request( - app_server_client, - unsupported.request_id, - unsupported.message, - ) - .await - { - tracing::warn!("{err}"); - } - return; - } - - let Some(thread_id) = server_request_thread_id(&request) else { - tracing::warn!("ignoring threadless app-server request"); - return; - }; - - let result = - if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() { - self.enqueue_primary_thread_request(request).await - } else { - self.enqueue_thread_request(thread_id, request).await - }; - if let Err(err) = result { - tracing::warn!("failed to enqueue app-server request: {err}"); - } - } - async fn reject_app_server_request( - &self, - app_server_client: &AppServerSession, - request_id: codex_app_server_protocol::RequestId, - reason: String, - ) -> std::result::Result<(), String> { - app_server_client - .reject_server_request( - request_id, - JSONRPCErrorError { - code: -32000, - message: reason, - data: None, - }, - ) - .await - .map_err(|err| format!("failed to reject app-server request: {err}")) - } -} - -fn server_request_thread_id(request: &ServerRequest) -> Option { - match request { - ServerRequest::CommandExecutionRequestApproval { params, .. } => { - ThreadId::from_string(¶ms.thread_id).ok() - } - ServerRequest::FileChangeRequestApproval { params, .. } => { - ThreadId::from_string(¶ms.thread_id).ok() - } - ServerRequest::ToolRequestUserInput { params, .. } => { - ThreadId::from_string(¶ms.thread_id).ok() - } - ServerRequest::McpServerElicitationRequest { params, .. } => { - ThreadId::from_string(¶ms.thread_id).ok() - } - ServerRequest::PermissionsRequestApproval { params, .. } => { - ThreadId::from_string(¶ms.thread_id).ok() - } - ServerRequest::DynamicToolCall { params, .. } => { - ThreadId::from_string(¶ms.thread_id).ok() - } - ServerRequest::ChatgptAuthTokensRefresh { .. } - | ServerRequest::ApplyPatchApproval { .. } - | ServerRequest::ExecCommandApproval { .. } => None, - } -} - -#[derive(Debug, PartialEq, Eq)] -enum ServerNotificationThreadTarget { - Thread(ThreadId), - InvalidThreadId(String), - Global, -} - -fn server_notification_thread_target( - notification: &ServerNotification, -) -> ServerNotificationThreadTarget { - let thread_id = match notification { - ServerNotification::Error(notification) => Some(notification.thread_id.as_str()), - ServerNotification::ThreadStarted(notification) => Some(notification.thread.id.as_str()), - ServerNotification::ThreadStatusChanged(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadArchived(notification) => Some(notification.thread_id.as_str()), - ServerNotification::ThreadUnarchived(notification) => Some(notification.thread_id.as_str()), - ServerNotification::ThreadClosed(notification) => Some(notification.thread_id.as_str()), - ServerNotification::ThreadNameUpdated(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadTokenUsageUpdated(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadGoalUpdated(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadGoalCleared(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()), - ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()), - ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()), - ServerNotification::HookCompleted(notification) => Some(notification.thread_id.as_str()), - ServerNotification::TurnDiffUpdated(notification) => Some(notification.thread_id.as_str()), - ServerNotification::TurnPlanUpdated(notification) => Some(notification.thread_id.as_str()), - ServerNotification::ItemStarted(notification) => Some(notification.thread_id.as_str()), - ServerNotification::ItemGuardianApprovalReviewStarted(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ItemCompleted(notification) => Some(notification.thread_id.as_str()), - ServerNotification::RawResponseItemCompleted(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::AgentMessageDelta(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::PlanDelta(notification) => Some(notification.thread_id.as_str()), - ServerNotification::CommandExecutionOutputDelta(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::TerminalInteraction(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::FileChangeOutputDelta(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::FileChangePatchUpdated(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ServerRequestResolved(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::McpToolCallProgress(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ReasoningSummaryTextDelta(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ReasoningSummaryPartAdded(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ReasoningTextDelta(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ContextCompacted(notification) => Some(notification.thread_id.as_str()), - ServerNotification::ModelRerouted(notification) => Some(notification.thread_id.as_str()), - ServerNotification::ModelVerification(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadRealtimeStarted(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadRealtimeItemAdded(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadRealtimeTranscriptDelta(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadRealtimeTranscriptDone(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadRealtimeSdp(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadRealtimeError(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::ThreadRealtimeClosed(notification) => { - Some(notification.thread_id.as_str()) - } - ServerNotification::Warning(notification) => notification.thread_id.as_deref(), - ServerNotification::GuardianWarning(notification) => Some(notification.thread_id.as_str()), - ServerNotification::SkillsChanged(_) - | ServerNotification::McpServerStatusUpdated(_) - | ServerNotification::McpServerOauthLoginCompleted(_) - | ServerNotification::AccountUpdated(_) - | ServerNotification::AccountRateLimitsUpdated(_) - | ServerNotification::AppListUpdated(_) - | ServerNotification::RemoteControlStatusChanged(_) - | ServerNotification::ExternalAgentConfigImportCompleted(_) - | ServerNotification::DeprecationNotice(_) - | ServerNotification::ConfigWarning(_) - | ServerNotification::FuzzyFileSearchSessionUpdated(_) - | ServerNotification::FuzzyFileSearchSessionCompleted(_) - | ServerNotification::CommandExecOutputDelta(_) - | ServerNotification::FsChanged(_) - | ServerNotification::WindowsWorldWritableWarning(_) - | ServerNotification::WindowsSandboxSetupCompleted(_) - | ServerNotification::AccountLoginCompleted(_) => None, - }; - - match thread_id { - Some(thread_id) => match ThreadId::from_string(thread_id) { - Ok(thread_id) => ServerNotificationThreadTarget::Thread(thread_id), - Err(_) => ServerNotificationThreadTarget::InvalidThreadId(thread_id.to_string()), - }, - None => ServerNotificationThreadTarget::Global, - } -} - -#[cfg(test)] -/// Convert a `Thread` snapshot into a flat sequence of protocol `Event`s -/// suitable for replaying into the TUI event store. -/// -/// Each turn is expanded into `TurnStarted`, zero or more `ItemCompleted`, -/// and a terminal event that matches the turn's `TurnStatus`. Returns an -/// empty vec (with a warning log) if the thread ID is not a valid UUID. -pub(super) fn thread_snapshot_events( - thread: &Thread, - show_raw_agent_reasoning: bool, -) -> Vec { - let Ok(thread_id) = ThreadId::from_string(&thread.id) else { - tracing::warn!( - thread_id = %thread.id, - "ignoring app-server thread snapshot with invalid thread id" - ); - return Vec::new(); - }; - - thread - .turns - .iter() - .flat_map(|turn| turn_snapshot_events(thread_id, turn, show_raw_agent_reasoning)) - .collect() -} - -#[cfg(test)] -fn server_notification_thread_events( - notification: ServerNotification, -) -> Option<(ThreadId, Vec)> { - match notification { - ServerNotification::ThreadTokenUsageUpdated(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(TokenUsageInfo { - total_token_usage: token_usage_from_app_server( - notification.token_usage.total, - ), - last_token_usage: token_usage_from_app_server( - notification.token_usage.last, - ), - model_context_window: notification.token_usage.model_context_window, - }), - rate_limits: None, - }), - }], - )), - ServerNotification::Error(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::Error(ErrorEvent { - message: notification.error.message, - codex_error_info: notification - .error - .codex_error_info - .and_then(app_server_codex_error_info_to_core), - }), - }], - )), - ServerNotification::ThreadNameUpdated(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { - thread_id: ThreadId::from_string(¬ification.thread_id).ok()?, - thread_name: notification.thread_name, - }), - }], - )), - ServerNotification::TurnStarted(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: notification.turn.id, - started_at: notification.turn.started_at, - model_context_window: None, - collaboration_mode_kind: ModeKind::default(), - }), - }], - )), - ServerNotification::TurnCompleted(notification) => { - let thread_id = ThreadId::from_string(¬ification.thread_id).ok()?; - let mut events = Vec::new(); - append_terminal_turn_events( - &mut events, - ¬ification.turn, - /*include_failed_error*/ false, - ); - Some((thread_id, events)) - } - ServerNotification::ItemStarted(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - command_execution_started_event(¬ification.turn_id, ¬ification.item).or_else( - || { - Some(vec![Event { - id: String::new(), - msg: EventMsg::ItemStarted(ItemStartedEvent { - thread_id: ThreadId::from_string(¬ification.thread_id).ok()?, - turn_id: notification.turn_id.clone(), - item: thread_item_to_core(¬ification.item)?, - }), - }]) - }, - )?, - )), - ServerNotification::ItemCompleted(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - command_execution_completed_event(¬ification.turn_id, ¬ification.item).or_else( - || { - Some(vec![Event { - id: String::new(), - msg: EventMsg::ItemCompleted(ItemCompletedEvent { - thread_id: ThreadId::from_string(¬ification.thread_id).ok()?, - turn_id: notification.turn_id.clone(), - item: thread_item_to_core(¬ification.item)?, - }), - }]) - }, - )?, - )), - ServerNotification::CommandExecutionOutputDelta(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { - call_id: notification.item_id, - stream: ExecOutputStream::Stdout, - chunk: notification.delta.into_bytes(), - }), - }], - )), - ServerNotification::AgentMessageDelta(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: notification.delta, - }), - }], - )), - ServerNotification::PlanDelta(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::PlanDelta(PlanDeltaEvent { - thread_id: notification.thread_id, - turn_id: notification.turn_id, - item_id: notification.item_id, - delta: notification.delta, - }), - }], - )), - ServerNotification::ReasoningSummaryTextDelta(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: notification.delta, - }), - }], - )), - ServerNotification::ReasoningTextDelta(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { - delta: notification.delta, - }), - }], - )), - ServerNotification::ThreadRealtimeStarted(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent { - session_id: notification.session_id, - version: notification.version, - }), - }], - )), - ServerNotification::ThreadRealtimeItemAdded(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::ConversationItemAdded(notification.item), - }), - }], - )), - ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::AudioOut(notification.audio.into()), - }), - }], - )), - ServerNotification::ThreadRealtimeSdp(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::RealtimeConversationSdp( - codex_protocol::protocol::RealtimeConversationSdpEvent { - sdp: notification.sdp, - }, - ), - }], - )), - ServerNotification::ThreadRealtimeError(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::Error(notification.message), - }), - }], - )), - ServerNotification::ThreadRealtimeClosed(notification) => Some(( - ThreadId::from_string(¬ification.thread_id).ok()?, - vec![Event { - id: String::new(), - msg: EventMsg::RealtimeConversationClosed(RealtimeConversationClosedEvent { - reason: notification.reason, - }), - }], - )), - _ => None, - } -} - -#[cfg(test)] -fn token_usage_from_app_server( - value: codex_app_server_protocol::TokenUsageBreakdown, -) -> TokenUsage { - TokenUsage { - input_tokens: value.input_tokens, - cached_input_tokens: value.cached_input_tokens, - output_tokens: value.output_tokens, - reasoning_output_tokens: value.reasoning_output_tokens, - total_tokens: value.total_tokens, - } -} - -/// Expand a single `Turn` into the event sequence the TUI would have -/// observed if it had been connected for the turn's entire lifetime. -/// -/// Snapshot replay keeps committed-item semantics for user / plan / -/// agent-message items, while replaying the legacy events that still -/// drive rendering for reasoning, web-search, image-generation, and -/// context-compaction history cells. -#[cfg(test)] -fn turn_snapshot_events( - thread_id: ThreadId, - turn: &Turn, - show_raw_agent_reasoning: bool, -) -> Vec { - let mut events = vec![Event { - id: String::new(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: turn.id.clone(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::default(), - }), - }]; - - for item in &turn.items { - if let Some(command_events) = command_execution_snapshot_events(&turn.id, item) { - events.extend(command_events); - continue; - } - - let Some(item) = thread_item_to_core(item) else { - continue; - }; - match item { - TurnItem::UserMessage(_) | TurnItem::Plan(_) | TurnItem::AgentMessage(_) => { - events.push(Event { - id: String::new(), - msg: EventMsg::ItemCompleted(ItemCompletedEvent { - thread_id, - turn_id: turn.id.clone(), - item, - }), - }); - } - TurnItem::Reasoning(_) - | TurnItem::WebSearch(_) - | TurnItem::ImageGeneration(_) - | TurnItem::ContextCompaction(_) => { - events.extend( - item.as_legacy_events(show_raw_agent_reasoning) - .into_iter() - .map(|msg| Event { - id: String::new(), - msg, - }), - ); - } - TurnItem::HookPrompt(_) => {} - } - } - - append_terminal_turn_events(&mut events, turn, /*include_failed_error*/ true); - - events -} - -/// Append the terminal event(s) for a turn based on its `TurnStatus`. -/// -/// This function is shared between the live notification bridge -/// (`TurnCompleted` handling) and the snapshot replay path so that both -/// produce identical `EventMsg` sequences for the same turn status. -/// -/// - `Completed` → `TurnComplete` -/// - `Interrupted` → `TurnAborted { reason: Interrupted }` -/// - `Failed` → `Error` (if present) then `TurnComplete` -/// - `InProgress` → no events (the turn is still running) -#[cfg(test)] -fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_failed_error: bool) { - match turn.status { - TurnStatus::Completed => events.push(Event { - id: String::new(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: turn.id.clone(), - last_agent_message: None, - completed_at: turn.completed_at, - duration_ms: turn.duration_ms, - time_to_first_token_ms: None, - }), - }), - TurnStatus::Interrupted => events.push(Event { - id: String::new(), - msg: EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn.id.clone()), - reason: TurnAbortReason::Interrupted, - completed_at: turn.completed_at, - duration_ms: turn.duration_ms, - }), - }), - TurnStatus::Failed => { - if include_failed_error && let Some(error) = &turn.error { - events.push(Event { - id: String::new(), - msg: EventMsg::Error(ErrorEvent { - message: error.message.clone(), - codex_error_info: error - .codex_error_info - .clone() - .and_then(app_server_codex_error_info_to_core), - }), - }); - } - events.push(Event { - id: String::new(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: turn.id.clone(), - last_agent_message: None, - completed_at: turn.completed_at, - duration_ms: turn.duration_ms, - time_to_first_token_ms: None, - }), - }); - } - TurnStatus::InProgress => { - // Preserve unfinished turns during snapshot replay without emitting completion events. - } - } -} - -#[cfg(test)] -fn thread_item_to_core(item: &ThreadItem) -> Option { - match item { - ThreadItem::UserMessage { id, content } => Some(TurnItem::UserMessage(UserMessageItem { - id: id.clone(), - content: content - .iter() - .cloned() - .map(codex_app_server_protocol::UserInput::into_core) - .collect(), - })), - ThreadItem::AgentMessage { - id, - text, - phase, - memory_citation, - } => Some(TurnItem::AgentMessage(AgentMessageItem { - id: id.clone(), - content: vec![AgentMessageContent::Text { text: text.clone() }], - phase: phase.clone(), - memory_citation: memory_citation.clone().map(|citation| { - codex_protocol::memory_citation::MemoryCitation { - entries: citation - .entries - .into_iter() - .map( - |entry| codex_protocol::memory_citation::MemoryCitationEntry { - path: entry.path, - line_start: entry.line_start, - line_end: entry.line_end, - note: entry.note, - }, - ) - .collect(), - rollout_ids: citation.thread_ids, - } - }), - })), - ThreadItem::Plan { id, text } => Some(TurnItem::Plan(PlanItem { - id: id.clone(), - text: text.clone(), - })), - ThreadItem::Reasoning { - id, - summary, - content, - } => Some(TurnItem::Reasoning(ReasoningItem { - id: id.clone(), - summary_text: summary.clone(), - raw_content: content.clone(), - })), - ThreadItem::WebSearch { id, query, action } => Some(TurnItem::WebSearch(WebSearchItem { - id: id.clone(), - query: query.clone(), - action: app_server_web_search_action_to_core(action.clone()?)?, - })), - ThreadItem::ImageGeneration { - id, - status, - revised_prompt, - result, - saved_path, - } => Some(TurnItem::ImageGeneration(ImageGenerationItem { - id: id.clone(), - status: status.clone(), - revised_prompt: revised_prompt.clone(), - result: result.clone(), - saved_path: saved_path.clone(), - })), - ThreadItem::ContextCompaction { id } => { - Some(TurnItem::ContextCompaction(ContextCompactionItem { - id: id.clone(), - })) - } - ThreadItem::CommandExecution { .. } - | ThreadItem::FileChange { .. } - | ThreadItem::McpToolCall { .. } - | ThreadItem::DynamicToolCall { .. } - | ThreadItem::CollabAgentToolCall { .. } - | ThreadItem::HookPrompt { .. } - | ThreadItem::ImageView { .. } - | ThreadItem::EnteredReviewMode { .. } - | ThreadItem::ExitedReviewMode { .. } => { - tracing::debug!("ignoring unsupported app-server thread item in TUI adapter"); - None - } - } -} - -#[cfg(test)] -fn command_execution_started_event(turn_id: &str, item: &ThreadItem) -> Option> { - let ThreadItem::CommandExecution { - id, - command, - cwd, - process_id, - source, - command_actions, - .. - } = item - else { - return None; - }; - - Some(vec![Event { - id: String::new(), - msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: id.clone(), - process_id: process_id.clone(), - turn_id: turn_id.to_string(), - command: split_command_string(command), - cwd: cwd.clone(), - parsed_cmd: command_actions - .iter() - .cloned() - .map(codex_app_server_protocol::CommandAction::into_core) - .collect(), - source: source.to_core(), - interaction_input: None, - }), - }]) -} - -#[cfg(test)] -fn command_execution_completed_event(turn_id: &str, item: &ThreadItem) -> Option> { - let ThreadItem::CommandExecution { - id, - command, - cwd, - process_id, - source, - status, - command_actions, - aggregated_output, - exit_code, - duration_ms, - } = item - else { - return None; - }; - - if matches!( - status, - codex_app_server_protocol::CommandExecutionStatus::InProgress - ) { - return Some(Vec::new()); - } - - let status = match status { - codex_app_server_protocol::CommandExecutionStatus::InProgress => return Some(Vec::new()), - codex_app_server_protocol::CommandExecutionStatus::Completed => { - ExecCommandStatus::Completed - } - codex_app_server_protocol::CommandExecutionStatus::Failed => ExecCommandStatus::Failed, - codex_app_server_protocol::CommandExecutionStatus::Declined => ExecCommandStatus::Declined, - }; - - let duration = Duration::from_millis( - duration_ms - .and_then(|value| u64::try_from(value).ok()) - .unwrap_or_default(), - ); - let aggregated_output = aggregated_output.clone().unwrap_or_default(); - - Some(vec![Event { - id: String::new(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: id.clone(), - process_id: process_id.clone(), - turn_id: turn_id.to_string(), - command: split_command_string(command), - cwd: cwd.clone(), - parsed_cmd: command_actions - .iter() - .cloned() - .map(codex_app_server_protocol::CommandAction::into_core) - .collect(), - source: source.to_core(), - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: aggregated_output.clone(), - exit_code: exit_code.unwrap_or(-1), - duration, - formatted_output: aggregated_output, - status, - }), - }]) -} - -#[cfg(test)] -fn command_execution_snapshot_events(turn_id: &str, item: &ThreadItem) -> Option> { - let mut events = command_execution_started_event(turn_id, item)?; - if let Some(end_events) = command_execution_completed_event(turn_id, item) { - events.extend(end_events); - } - Some(events) -} - -#[cfg(test)] -fn app_server_web_search_action_to_core( - action: codex_app_server_protocol::WebSearchAction, -) -> Option { - match action { - codex_app_server_protocol::WebSearchAction::Search { query, queries } => { - Some(codex_protocol::models::WebSearchAction::Search { query, queries }) - } - codex_app_server_protocol::WebSearchAction::OpenPage { url } => { - Some(codex_protocol::models::WebSearchAction::OpenPage { url }) - } - codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => { - Some(codex_protocol::models::WebSearchAction::FindInPage { url, pattern }) - } - codex_app_server_protocol::WebSearchAction::Other => { - Some(codex_protocol::models::WebSearchAction::Other) - } - } -} - -#[cfg(test)] -fn app_server_codex_error_info_to_core( - value: codex_app_server_protocol::CodexErrorInfo, -) -> Option { - serde_json::from_value(serde_json::to_value(value).ok()?).ok() -} - -#[cfg(test)] -mod tests { - use super::ServerNotificationThreadTarget; - use super::command_execution_started_event; - use super::server_notification_thread_events; - use super::server_notification_thread_target; - use super::thread_snapshot_events; - use super::turn_snapshot_events; - use codex_app_server_protocol::AgentMessageDeltaNotification; - use codex_app_server_protocol::CodexErrorInfo; - use codex_app_server_protocol::CommandAction; - use codex_app_server_protocol::CommandExecutionOutputDeltaNotification; - use codex_app_server_protocol::CommandExecutionSource; - use codex_app_server_protocol::CommandExecutionStatus; - use codex_app_server_protocol::GuardianWarningNotification; - use codex_app_server_protocol::ItemCompletedNotification; - use codex_app_server_protocol::ItemStartedNotification; - use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; - use codex_app_server_protocol::ServerNotification; - use codex_app_server_protocol::Thread; - use codex_app_server_protocol::ThreadItem; - use codex_app_server_protocol::ThreadStatus; - use codex_app_server_protocol::Turn; - use codex_app_server_protocol::TurnCompletedNotification; - use codex_app_server_protocol::TurnError; - use codex_app_server_protocol::TurnStatus; - use codex_app_server_protocol::WarningNotification; - use codex_protocol::ThreadId; - use codex_protocol::items::AgentMessageContent; - use codex_protocol::items::AgentMessageItem; - use codex_protocol::items::TurnItem; - use codex_protocol::models::MessagePhase; - use codex_protocol::protocol::EventMsg; - use codex_protocol::protocol::ExecCommandSource; - use codex_protocol::protocol::SessionSource; - use codex_protocol::protocol::TurnAbortReason; - use codex_protocol::protocol::TurnAbortedEvent; - use codex_utils_absolute_path::test_support::PathBufExt; - use codex_utils_absolute_path::test_support::test_path_buf; - use pretty_assertions::assert_eq; - - #[test] - fn bridges_completed_agent_messages_from_server_notifications() { - let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); - let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string(); - let item_id = "msg_123".to_string(); - - let (actual_thread_id, events) = server_notification_thread_events( - ServerNotification::ItemCompleted(ItemCompletedNotification { - item: ThreadItem::AgentMessage { - id: item_id, - text: "Hello from your coding assistant.".to_string(), - phase: Some(MessagePhase::FinalAnswer), - memory_citation: None, - }, - thread_id: thread_id.clone(), - turn_id: turn_id.clone(), - }), - ) - .expect("notification should bridge"); - - assert_eq!( - actual_thread_id, - ThreadId::from_string(&thread_id).expect("valid thread id") - ); - let [event] = events.as_slice() else { - panic!("expected one bridged event"); - }; - assert_eq!(event.id, String::new()); - let EventMsg::ItemCompleted(completed) = &event.msg else { - panic!("expected item completed event"); - }; - assert_eq!( - completed.thread_id, - ThreadId::from_string(&thread_id).expect("valid thread id") - ); - assert_eq!(completed.turn_id, turn_id); - match &completed.item { - TurnItem::AgentMessage(AgentMessageItem { - id, content, phase, .. - }) => { - assert_eq!(id, "msg_123"); - let [AgentMessageContent::Text { text }] = content.as_slice() else { - panic!("expected a single text content item"); - }; - assert_eq!(text, "Hello from your coding assistant."); - assert_eq!(*phase, Some(MessagePhase::FinalAnswer)); - } - _ => panic!("expected bridged agent message item"), - } - } - - #[test] - fn bridges_turn_completion_from_server_notifications() { - let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); - let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string(); - - let (actual_thread_id, events) = server_notification_thread_events( - ServerNotification::TurnCompleted(TurnCompletedNotification { - thread_id: thread_id.clone(), - turn: Turn { - id: turn_id.clone(), - items: Vec::new(), - status: TurnStatus::Completed, - error: None, - started_at: None, - completed_at: Some(0), - duration_ms: None, - }, - }), - ) - .expect("notification should bridge"); - - assert_eq!( - actual_thread_id, - ThreadId::from_string(&thread_id).expect("valid thread id") - ); - let [event] = events.as_slice() else { - panic!("expected one bridged event"); - }; - assert_eq!(event.id, String::new()); - let EventMsg::TurnComplete(completed) = &event.msg else { - panic!("expected turn complete event"); - }; - assert_eq!(completed.turn_id, turn_id); - assert_eq!(completed.last_agent_message, None); - assert_eq!(completed.completed_at, Some(0)); - assert_eq!(completed.duration_ms, None); - } - - #[test] - fn bridges_command_execution_notifications_into_legacy_exec_events() { - let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); - let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string(); - let item = ThreadItem::CommandExecution { - id: "cmd-1".to_string(), - command: "printf 'hello world\\n'".to_string(), - cwd: test_path_buf("/tmp").abs(), - process_id: None, - source: CommandExecutionSource::UserShell, - status: CommandExecutionStatus::InProgress, - command_actions: vec![CommandAction::Unknown { - command: "printf hello world".to_string(), - }], - aggregated_output: None, - exit_code: None, - duration_ms: None, - }; - - let (_, started_events) = server_notification_thread_events( - ServerNotification::ItemStarted(ItemStartedNotification { - item, - thread_id: thread_id.clone(), - turn_id: turn_id.clone(), - }), - ) - .expect("command execution start should bridge"); - let [started] = started_events.as_slice() else { - panic!("expected one started event"); - }; - let EventMsg::ExecCommandBegin(begin) = &started.msg else { - panic!("expected exec begin event"); - }; - assert_eq!(begin.call_id, "cmd-1"); - assert_eq!( - begin.command, - vec!["printf".to_string(), "hello world\\n".to_string()] - ); - assert_eq!(begin.cwd.as_path(), test_path_buf("/tmp").as_path()); - assert_eq!(begin.source, ExecCommandSource::UserShell); - - let (_, delta_events) = - server_notification_thread_events(ServerNotification::CommandExecutionOutputDelta( - CommandExecutionOutputDeltaNotification { - thread_id: thread_id.clone(), - turn_id: turn_id.clone(), - item_id: "cmd-1".to_string(), - delta: "hello world\n".to_string(), - }, - )) - .expect("command execution delta should bridge"); - let [delta] = delta_events.as_slice() else { - panic!("expected one delta event"); - }; - let EventMsg::ExecCommandOutputDelta(delta) = &delta.msg else { - panic!("expected exec output delta event"); - }; - assert_eq!(delta.call_id, "cmd-1"); - assert_eq!(delta.chunk, b"hello world\n"); - - let completed_item = ThreadItem::CommandExecution { - id: "cmd-1".to_string(), - command: "printf 'hello world\\n'".to_string(), - cwd: test_path_buf("/tmp").abs(), - process_id: None, - source: CommandExecutionSource::UserShell, - status: CommandExecutionStatus::Completed, - command_actions: vec![CommandAction::Unknown { - command: "printf hello world".to_string(), - }], - aggregated_output: Some("hello world\n".to_string()), - exit_code: Some(0), - duration_ms: Some(5), - }; - let (_, completed_events) = server_notification_thread_events( - ServerNotification::ItemCompleted(ItemCompletedNotification { - item: completed_item, - thread_id, - turn_id, - }), - ) - .expect("command execution completion should bridge"); - let [completed] = completed_events.as_slice() else { - panic!("expected one completed event"); - }; - let EventMsg::ExecCommandEnd(end) = &completed.msg else { - panic!("expected exec end event"); - }; - assert_eq!(end.call_id, "cmd-1"); - assert_eq!(end.exit_code, 0); - assert_eq!(end.formatted_output, "hello world\n"); - assert_eq!(end.aggregated_output, "hello world\n"); - assert_eq!(end.source, ExecCommandSource::UserShell); - } - - #[test] - fn command_execution_snapshot_preserves_non_roundtrippable_command_strings() { - let item = ThreadItem::CommandExecution { - id: "cmd-1".to_string(), - command: r#"C:\Program Files\Git\bin\bash.exe -lc "echo hi""#.to_string(), - cwd: test_path_buf("/tmp").abs(), - process_id: None, - source: CommandExecutionSource::UserShell, - status: CommandExecutionStatus::InProgress, - command_actions: vec![], - aggregated_output: None, - exit_code: None, - duration_ms: None, - }; - - let events = - command_execution_started_event("turn-1", &item).expect("command execution start"); - let [started] = events.as_slice() else { - panic!("expected one started event"); - }; - let EventMsg::ExecCommandBegin(begin) = &started.msg else { - panic!("expected exec begin event"); - }; - assert_eq!( - begin.command, - vec![r#"C:\Program Files\Git\bin\bash.exe -lc "echo hi""#.to_string()] - ); - } - - #[test] - fn replays_command_execution_items_from_thread_snapshots() { - let thread = Thread { - id: "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(), - forked_from_id: None, - preview: String::new(), - ephemeral: false, - model_provider: "openai".to_string(), - created_at: 1, - updated_at: 1, - status: ThreadStatus::Idle, - path: None, - cwd: test_path_buf("/tmp").abs(), - cli_version: "test".to_string(), - source: SessionSource::Cli.into(), - agent_nickname: None, - agent_role: None, - git_info: None, - name: None, - turns: vec![Turn { - id: "turn-1".to_string(), - items: vec![ThreadItem::CommandExecution { - id: "cmd-1".to_string(), - command: "printf 'hello world\\n'".to_string(), - cwd: test_path_buf("/tmp").abs(), - process_id: None, - source: CommandExecutionSource::UserShell, - status: CommandExecutionStatus::Completed, - command_actions: vec![CommandAction::Unknown { - command: "printf hello world".to_string(), - }], - aggregated_output: Some("hello world\n".to_string()), - exit_code: Some(0), - duration_ms: Some(5), - }], - status: TurnStatus::Completed, - error: None, - started_at: None, - completed_at: None, - duration_ms: None, - }], - }; - - let events = thread_snapshot_events(&thread, /*show_raw_agent_reasoning*/ false); - assert!(matches!(events[0].msg, EventMsg::TurnStarted(_))); - let EventMsg::ExecCommandBegin(begin) = &events[1].msg else { - panic!("expected exec begin event"); - }; - assert_eq!(begin.call_id, "cmd-1"); - assert_eq!(begin.source, ExecCommandSource::UserShell); - let EventMsg::ExecCommandEnd(end) = &events[2].msg else { - panic!("expected exec end event"); - }; - assert_eq!(end.call_id, "cmd-1"); - assert_eq!(end.formatted_output, "hello world\n"); - assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); - } - - #[test] - fn bridges_interrupted_turn_completion_from_server_notifications() { - let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); - let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string(); - - let (actual_thread_id, events) = server_notification_thread_events( - ServerNotification::TurnCompleted(TurnCompletedNotification { - thread_id: thread_id.clone(), - turn: Turn { - id: turn_id.clone(), - items: Vec::new(), - status: TurnStatus::Interrupted, - error: None, - started_at: None, - completed_at: Some(0), - duration_ms: None, - }, - }), - ) - .expect("notification should bridge"); - - assert_eq!( - actual_thread_id, - ThreadId::from_string(&thread_id).expect("valid thread id") - ); - let [event] = events.as_slice() else { - panic!("expected one bridged event"); - }; - let EventMsg::TurnAborted(aborted) = &event.msg else { - panic!("expected turn aborted event"); - }; - assert_eq!(aborted.turn_id.as_deref(), Some(turn_id.as_str())); - assert_eq!(aborted.reason, TurnAbortReason::Interrupted); - } - - #[test] - fn bridges_failed_turn_completion_from_server_notifications() { - let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); - let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string(); - - let (actual_thread_id, events) = server_notification_thread_events( - ServerNotification::TurnCompleted(TurnCompletedNotification { - thread_id: thread_id.clone(), - turn: Turn { - id: turn_id.clone(), - items: Vec::new(), - status: TurnStatus::Failed, - error: Some(TurnError { - message: "request failed".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: None, - }), - started_at: None, - completed_at: Some(0), - duration_ms: None, - }, - }), - ) - .expect("notification should bridge"); - - assert_eq!( - actual_thread_id, - ThreadId::from_string(&thread_id).expect("valid thread id") - ); - let [complete_event] = events.as_slice() else { - panic!("expected turn completion only"); - }; - let EventMsg::TurnComplete(completed) = &complete_event.msg else { - panic!("expected turn complete event"); - }; - assert_eq!(completed.turn_id, turn_id); - assert_eq!(completed.last_agent_message, None); - } - - #[test] - fn bridges_text_deltas_from_server_notifications() { - let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); - - let (_, agent_events) = server_notification_thread_events( - ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification { - thread_id: thread_id.clone(), - turn_id: "turn".to_string(), - item_id: "item".to_string(), - delta: "Hello".to_string(), - }), - ) - .expect("notification should bridge"); - let [agent_event] = agent_events.as_slice() else { - panic!("expected one bridged agent delta event"); - }; - assert_eq!(agent_event.id, String::new()); - let EventMsg::AgentMessageDelta(delta) = &agent_event.msg else { - panic!("expected bridged agent message delta"); - }; - assert_eq!(delta.delta, "Hello"); - - let (_, reasoning_events) = server_notification_thread_events( - ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { - thread_id, - turn_id: "turn".to_string(), - item_id: "item".to_string(), - delta: "Thinking".to_string(), - summary_index: 0, - }), - ) - .expect("notification should bridge"); - let [reasoning_event] = reasoning_events.as_slice() else { - panic!("expected one bridged reasoning delta event"); - }; - assert_eq!(reasoning_event.id, String::new()); - let EventMsg::AgentReasoningDelta(delta) = &reasoning_event.msg else { - panic!("expected bridged reasoning delta"); - }; - assert_eq!(delta.delta, "Thinking"); - } - - #[test] - fn bridges_thread_snapshot_turns_for_resume_restore() { - let thread_id = ThreadId::new(); - let events = thread_snapshot_events( - &Thread { - id: thread_id.to_string(), - forked_from_id: None, - preview: "hello".to_string(), - ephemeral: false, - model_provider: "openai".to_string(), - created_at: 0, - updated_at: 0, - status: ThreadStatus::Idle, - path: None, - cwd: test_path_buf("/tmp/project").abs(), - cli_version: "test".to_string(), - source: SessionSource::Cli.into(), - agent_nickname: None, - agent_role: None, - git_info: None, - name: Some("restore".to_string()), - turns: vec![ - Turn { - id: "turn-complete".to_string(), - items: vec![ - ThreadItem::UserMessage { - id: "user-1".to_string(), - content: vec![codex_app_server_protocol::UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }], - }, - ThreadItem::AgentMessage { - id: "assistant-1".to_string(), - text: "hi".to_string(), - phase: Some(MessagePhase::FinalAnswer), - memory_citation: None, - }, - ], - status: TurnStatus::Completed, - error: None, - started_at: None, - completed_at: None, - duration_ms: None, - }, - Turn { - id: "turn-interrupted".to_string(), - items: Vec::new(), - status: TurnStatus::Interrupted, - error: None, - started_at: None, - completed_at: None, - duration_ms: None, - }, - Turn { - id: "turn-failed".to_string(), - items: Vec::new(), - status: TurnStatus::Failed, - error: Some(TurnError { - message: "request failed".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: None, - }), - started_at: None, - completed_at: None, - duration_ms: None, - }, - ], - }, - /*show_raw_agent_reasoning*/ false, - ); - - assert_eq!(events.len(), 9); - assert!(matches!(events[0].msg, EventMsg::TurnStarted(_))); - assert!(matches!(events[1].msg, EventMsg::ItemCompleted(_))); - assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); - assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); - assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { - turn_id, reason, .. - }) = &events[5].msg - else { - panic!("expected interrupted turn replay"); - }; - assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); - assert_eq!(*reason, TurnAbortReason::Interrupted); - assert!(matches!(events[6].msg, EventMsg::TurnStarted(_))); - let EventMsg::Error(error) = &events[7].msg else { - panic!("expected failed turn error replay"); - }; - assert_eq!(error.message, "request failed"); - assert_eq!( - error.codex_error_info, - Some(codex_protocol::protocol::CodexErrorInfo::Other) - ); - assert!(matches!(events[8].msg, EventMsg::TurnComplete(_))); - } - - #[test] - fn bridges_non_message_snapshot_items_via_legacy_events() { - let events = turn_snapshot_events( - ThreadId::new(), - &Turn { - id: "turn-complete".to_string(), - items: vec![ - ThreadItem::Reasoning { - id: "reasoning-1".to_string(), - summary: vec!["Need to inspect config".to_string()], - content: vec!["hidden chain".to_string()], - }, - ThreadItem::WebSearch { - id: "search-1".to_string(), - query: "ratatui stylize".to_string(), - action: Some(codex_app_server_protocol::WebSearchAction::Other), - }, - ThreadItem::ImageGeneration { - id: "image-1".to_string(), - status: "completed".to_string(), - revised_prompt: Some("diagram".to_string()), - result: "image.png".to_string(), - saved_path: None, - }, - ThreadItem::ContextCompaction { - id: "compact-1".to_string(), - }, - ], - status: TurnStatus::Completed, - error: None, - started_at: None, - completed_at: None, - duration_ms: None, - }, - /*show_raw_agent_reasoning*/ false, - ); - - assert_eq!(events.len(), 6); - assert!(matches!(events[0].msg, EventMsg::TurnStarted(_))); - let EventMsg::AgentReasoning(reasoning) = &events[1].msg else { - panic!("expected reasoning replay"); - }; - assert_eq!(reasoning.text, "Need to inspect config"); - let EventMsg::WebSearchEnd(web_search) = &events[2].msg else { - panic!("expected web search replay"); - }; - assert_eq!(web_search.call_id, "search-1"); - assert_eq!(web_search.query, "ratatui stylize"); - assert_eq!( - web_search.action, - codex_protocol::models::WebSearchAction::Other - ); - let EventMsg::ImageGenerationEnd(image_generation) = &events[3].msg else { - panic!("expected image generation replay"); - }; - assert_eq!(image_generation.call_id, "image-1"); - assert_eq!(image_generation.status, "completed"); - assert_eq!(image_generation.revised_prompt.as_deref(), Some("diagram")); - assert_eq!(image_generation.result, "image.png"); - assert!(matches!(events[4].msg, EventMsg::ContextCompacted(_))); - assert!(matches!(events[5].msg, EventMsg::TurnComplete(_))); - } - - #[test] - fn bridges_raw_reasoning_snapshot_items_when_enabled() { - let events = turn_snapshot_events( - ThreadId::new(), - &Turn { - id: "turn-complete".to_string(), - items: vec![ThreadItem::Reasoning { - id: "reasoning-1".to_string(), - summary: vec!["Need to inspect config".to_string()], - content: vec!["hidden chain".to_string()], - }], - status: TurnStatus::Completed, - error: None, - started_at: None, - completed_at: None, - duration_ms: None, - }, - /*show_raw_agent_reasoning*/ true, - ); - - assert_eq!(events.len(), 4); - assert!(matches!(events[0].msg, EventMsg::TurnStarted(_))); - let EventMsg::AgentReasoning(reasoning) = &events[1].msg else { - panic!("expected reasoning replay"); - }; - assert_eq!(reasoning.text, "Need to inspect config"); - let EventMsg::AgentReasoningRawContent(raw_reasoning) = &events[2].msg else { - panic!("expected raw reasoning replay"); - }; - assert_eq!(raw_reasoning.text, "hidden chain"); - assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); - } - - #[test] - fn warning_notifications_route_to_threads_when_thread_id_is_present() { - let thread_id = ThreadId::new(); - let notification = ServerNotification::Warning(WarningNotification { - thread_id: Some(thread_id.to_string()), - message: "warning".to_string(), - }); - - let target = server_notification_thread_target(¬ification); - - assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); - } - - #[test] - fn guardian_warning_notifications_route_to_threads() { - let thread_id = ThreadId::new(); - let notification = ServerNotification::GuardianWarning(GuardianWarningNotification { - thread_id: thread_id.to_string(), - message: "warning".to_string(), - }); - - let target = server_notification_thread_target(¬ification); - - assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); - } -} diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs new file mode 100644 index 0000000000..bc0567df51 --- /dev/null +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -0,0 +1,218 @@ +//! Thread targeting helpers for app-server requests and notifications. + +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_protocol::ThreadId; + +pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option { + match request { + ServerRequest::CommandExecutionRequestApproval { params, .. } => { + ThreadId::from_string(¶ms.thread_id).ok() + } + ServerRequest::FileChangeRequestApproval { params, .. } => { + ThreadId::from_string(¶ms.thread_id).ok() + } + ServerRequest::ToolRequestUserInput { params, .. } => { + ThreadId::from_string(¶ms.thread_id).ok() + } + ServerRequest::McpServerElicitationRequest { params, .. } => { + ThreadId::from_string(¶ms.thread_id).ok() + } + ServerRequest::PermissionsRequestApproval { params, .. } => { + ThreadId::from_string(¶ms.thread_id).ok() + } + ServerRequest::DynamicToolCall { params, .. } => { + ThreadId::from_string(¶ms.thread_id).ok() + } + ServerRequest::ChatgptAuthTokensRefresh { .. } + | ServerRequest::ApplyPatchApproval { .. } + | ServerRequest::ExecCommandApproval { .. } => None, + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum ServerNotificationThreadTarget { + Thread(ThreadId), + InvalidThreadId(String), + Global, +} + +pub(super) fn server_notification_thread_target( + notification: &ServerNotification, +) -> ServerNotificationThreadTarget { + let thread_id = match notification { + ServerNotification::Error(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadStarted(notification) => Some(notification.thread.id.as_str()), + ServerNotification::ThreadStatusChanged(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadArchived(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadUnarchived(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadClosed(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadNameUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadGoalUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadGoalCleared(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::HookCompleted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::TurnDiffUpdated(notification) => Some(notification.thread_id.as_str()), + ServerNotification::TurnPlanUpdated(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ItemStarted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ItemGuardianApprovalReviewStarted(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ItemCompleted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::RawResponseItemCompleted(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::AgentMessageDelta(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::PlanDelta(notification) => Some(notification.thread_id.as_str()), + ServerNotification::CommandExecutionOutputDelta(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::TerminalInteraction(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::FileChangeOutputDelta(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::FileChangePatchUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ServerRequestResolved(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::McpToolCallProgress(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ReasoningSummaryTextDelta(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ReasoningSummaryPartAdded(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ReasoningTextDelta(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ContextCompacted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ModelRerouted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ModelVerification(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadRealtimeStarted(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadRealtimeItemAdded(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadRealtimeTranscriptDelta(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadRealtimeTranscriptDone(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadRealtimeSdp(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadRealtimeError(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadRealtimeClosed(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::Warning(notification) => notification.thread_id.as_deref(), + ServerNotification::GuardianWarning(notification) => Some(notification.thread_id.as_str()), + ServerNotification::SkillsChanged(_) + | ServerNotification::McpServerStatusUpdated(_) + | ServerNotification::McpServerOauthLoginCompleted(_) + | ServerNotification::AccountUpdated(_) + | ServerNotification::AccountRateLimitsUpdated(_) + | ServerNotification::AppListUpdated(_) + | ServerNotification::RemoteControlStatusChanged(_) + | ServerNotification::ExternalAgentConfigImportCompleted(_) + | ServerNotification::DeprecationNotice(_) + | ServerNotification::ConfigWarning(_) + | ServerNotification::FuzzyFileSearchSessionUpdated(_) + | ServerNotification::FuzzyFileSearchSessionCompleted(_) + | ServerNotification::CommandExecOutputDelta(_) + | ServerNotification::FsChanged(_) + | ServerNotification::WindowsWorldWritableWarning(_) + | ServerNotification::WindowsSandboxSetupCompleted(_) + | ServerNotification::AccountLoginCompleted(_) => None, + }; + + match thread_id { + Some(thread_id) => match ThreadId::from_string(thread_id) { + Ok(thread_id) => ServerNotificationThreadTarget::Thread(thread_id), + Err(_) => ServerNotificationThreadTarget::InvalidThreadId(thread_id.to_string()), + }, + None => ServerNotificationThreadTarget::Global, + } +} + +#[cfg(test)] +mod tests { + use super::ServerNotificationThreadTarget; + use super::server_notification_thread_target; + use codex_app_server_protocol::GuardianWarningNotification; + use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::WarningNotification; + use codex_protocol::ThreadId; + use pretty_assertions::assert_eq; + + #[test] + fn warning_notifications_without_threads_are_global() { + let notification = ServerNotification::Warning(WarningNotification { + thread_id: None, + message: "warning".to_string(), + }); + + let target = server_notification_thread_target(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::Global); + } + + #[test] + fn warning_notifications_route_to_threads_when_thread_id_is_present() { + let thread_id = ThreadId::new(); + let notification = ServerNotification::Warning(WarningNotification { + thread_id: Some(thread_id.to_string()), + message: "warning".to_string(), + }); + + let target = server_notification_thread_target(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); + } + + #[test] + fn guardian_warning_notifications_route_to_threads() { + let thread_id = ThreadId::new(); + let notification = ServerNotification::GuardianWarning(GuardianWarningNotification { + thread_id: thread_id.to_string(), + message: "warning".to_string(), + }); + + let target = server_notification_thread_target(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); + } +} diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs new file mode 100644 index 0000000000..413e8b8c80 --- /dev/null +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -0,0 +1,186 @@ +//! App-server event stream handling for the TUI app. + +use super::App; +use super::app_server_event_targets::ServerNotificationThreadTarget; +use super::app_server_event_targets::server_notification_thread_target; +use super::app_server_event_targets::server_request_thread_id; +use crate::app_command::AppCommand; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::app_server_session::status_account_display_from_auth_mode; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; + +impl App { + fn refresh_mcp_startup_expected_servers_from_config(&mut self) { + let enabled_config_mcp_servers: Vec = self + .chat_widget + .config_ref() + .mcp_servers + .get() + .iter() + .filter_map(|(name, server)| server.enabled.then_some(name.clone())) + .collect(); + self.chat_widget + .set_mcp_startup_expected_servers(enabled_config_mcp_servers); + } + + pub(super) async fn handle_app_server_event( + &mut self, + app_server_client: &AppServerSession, + event: AppServerEvent, + ) { + match event { + AppServerEvent::Lagged { skipped } => { + tracing::warn!( + skipped, + "app-server event consumer lagged; dropping ignored events" + ); + self.refresh_mcp_startup_expected_servers_from_config(); + self.chat_widget.finish_mcp_startup_after_lag(); + } + AppServerEvent::ServerNotification(notification) => { + self.handle_server_notification_event(app_server_client, notification) + .await; + } + AppServerEvent::ServerRequest(request) => { + self.handle_server_request_event(app_server_client, request) + .await; + } + AppServerEvent::Disconnected { message } => { + tracing::warn!("app-server event stream disconnected: {message}"); + self.chat_widget.add_error_message(message.clone()); + self.app_event_tx.send(AppEvent::FatalExitRequest(message)); + } + } + } + + async fn handle_server_notification_event( + &mut self, + app_server_client: &AppServerSession, + notification: ServerNotification, + ) { + match ¬ification { + ServerNotification::ServerRequestResolved(notification) => { + if let Some(request) = self + .pending_app_server_requests + .resolve_notification(¬ification.request_id) + { + self.chat_widget.dismiss_app_server_request(&request); + } + } + ServerNotification::McpServerStatusUpdated(_) => { + self.refresh_mcp_startup_expected_servers_from_config(); + } + ServerNotification::AccountRateLimitsUpdated(notification) => { + self.chat_widget + .on_rate_limit_snapshot(Some(notification.rate_limits.clone())); + return; + } + ServerNotification::AccountUpdated(notification) => { + self.chat_widget.update_account_state( + status_account_display_from_auth_mode( + notification.auth_mode, + notification.plan_type, + ), + notification.plan_type, + matches!( + notification.auth_mode, + Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) + ), + ); + return; + } + ServerNotification::ExternalAgentConfigImportCompleted(_) => { + let cwd = self.chat_widget.config_ref().cwd.to_path_buf(); + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after external agent config import" + ); + } + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.fetch_plugins_list(app_server_client, cwd); + return; + } + _ => {} + } + + match server_notification_thread_target(¬ification) { + ServerNotificationThreadTarget::Thread(thread_id) => { + let result = if self.primary_thread_id == Some(thread_id) + || self.primary_thread_id.is_none() + { + self.enqueue_primary_thread_notification(notification).await + } else { + self.enqueue_thread_notification(thread_id, notification) + .await + }; + + if let Err(err) = result { + tracing::warn!("failed to enqueue app-server notification: {err}"); + } + return; + } + ServerNotificationThreadTarget::InvalidThreadId(thread_id) => { + tracing::warn!( + thread_id, + "ignoring app-server notification with invalid thread_id" + ); + return; + } + ServerNotificationThreadTarget::Global => {} + } + + self.chat_widget + .handle_server_notification(notification, /*replay_kind*/ None); + } + + async fn handle_server_request_event( + &mut self, + app_server_client: &AppServerSession, + request: ServerRequest, + ) { + if let Some(unsupported) = self + .pending_app_server_requests + .note_server_request(&request) + { + tracing::warn!( + request_id = ?unsupported.request_id, + message = unsupported.message, + "rejecting unsupported app-server request" + ); + self.chat_widget + .add_error_message(unsupported.message.clone()); + if let Err(err) = self + .reject_app_server_request( + app_server_client, + unsupported.request_id, + unsupported.message, + ) + .await + { + tracing::warn!("{err}"); + } + return; + } + + let Some(thread_id) = server_request_thread_id(&request) else { + tracing::warn!("ignoring threadless app-server request"); + return; + }; + + let result = + if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() { + self.enqueue_primary_thread_request(request).await + } else { + self.enqueue_thread_request(thread_id, request).await + }; + if let Err(err) = result { + tracing::warn!("failed to enqueue app-server request: {err}"); + } + } +} diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index 5c2c693855..c1926041c6 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -1,12 +1,13 @@ use std::collections::HashMap; use std::collections::VecDeque; +use super::App; use crate::app_command::AppCommand; -use crate::app_command::AppCommandView; use crate::app_server_approval_conversions::granted_permission_profile_from_request; +use crate::app_server_session::AppServerSession; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; -use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeRequestApprovalResponse; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::PermissionsRequestApprovalResponse; @@ -14,7 +15,27 @@ use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ToolRequestUserInputResponse; use codex_protocol::mcp::RequestId as McpRequestId; -use codex_protocol::protocol::ReviewDecision; + +impl App { + pub(super) async fn reject_app_server_request( + &self, + app_server_client: &AppServerSession, + request_id: AppServerRequestId, + reason: String, + ) -> std::result::Result<(), String> { + app_server_client + .reject_server_request( + request_id, + JSONRPCErrorError { + code: -32000, + message: reason, + data: None, + }, + ) + .await + .map_err(|err| format!("failed to reject app-server request: {err}")) + } +} #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct AppServerRequestResolution { @@ -141,15 +162,15 @@ impl PendingAppServerRequests { T: Into, { let op: AppCommand = op.into(); - let resolution = match op.view() { - AppCommandView::ExecApproval { id, decision, .. } => self + let resolution = match &op { + AppCommand::ExecApproval { id, decision, .. } => self .exec_approvals .remove(id) .map(|request_id| { Ok::(AppServerRequestResolution { request_id, result: serde_json::to_value(CommandExecutionRequestApprovalResponse { - decision: decision.clone().into(), + decision: decision.clone(), }) .map_err(|err| { format!("failed to serialize command execution approval response: {err}") @@ -157,14 +178,14 @@ impl PendingAppServerRequests { }) }) .transpose()?, - AppCommandView::PatchApproval { id, decision } => self + AppCommand::PatchApproval { id, decision } => self .file_change_approvals .remove(id) .map(|request_id| { Ok::(AppServerRequestResolution { request_id, result: serde_json::to_value(FileChangeRequestApprovalResponse { - decision: file_change_decision(decision)?, + decision: decision.clone(), }) .map_err(|err| { format!("failed to serialize file change approval response: {err}") @@ -172,7 +193,7 @@ impl PendingAppServerRequests { }) }) .transpose()?, - AppCommandView::RequestPermissionsResponse { id, response } => self + AppCommand::RequestPermissionsResponse { id, response } => self .permissions_approvals .remove(id) .map(|request_id| { @@ -191,7 +212,7 @@ impl PendingAppServerRequests { }) }) .transpose()?, - AppCommandView::UserInputAnswer { id, response } => self + AppCommand::UserInputAnswer { id, response } => self .pop_user_input_request_for_turn(id) .map(|pending| { Ok::(AppServerRequestResolution { @@ -214,7 +235,7 @@ impl PendingAppServerRequests { }) }) .transpose()?, - AppCommandView::ResolveElicitation { + AppCommand::ResolveElicitation { server_name, request_id, decision, @@ -395,29 +416,16 @@ fn app_server_request_id_to_mcp_request_id(request_id: &AppServerRequestId) -> M } } -fn file_change_decision(decision: &ReviewDecision) -> Result { - match decision { - ReviewDecision::Approved => Ok(FileChangeApprovalDecision::Accept), - ReviewDecision::ApprovedForSession => Ok(FileChangeApprovalDecision::AcceptForSession), - ReviewDecision::Denied => Ok(FileChangeApprovalDecision::Decline), - ReviewDecision::TimedOut => Ok(FileChangeApprovalDecision::Decline), - ReviewDecision::Abort => Ok(FileChangeApprovalDecision::Cancel), - ReviewDecision::ApprovedExecpolicyAmendment { .. } => { - Err("execpolicy amendment is not a valid file change approval decision".to_string()) - } - ReviewDecision::NetworkPolicyAmendment { .. } => { - Err("network policy amendment is not a valid file change approval decision".to_string()) - } - } -} - #[cfg(test)] mod tests { use super::PendingAppServerRequests; use super::ResolvedAppServerRequest; + use crate::app_command::AppCommand as Op; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; + use codex_app_server_protocol::CommandExecutionApprovalDecision; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; + use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::McpElicitationObjectType; use codex_app_server_protocol::McpElicitationSchema; @@ -432,12 +440,9 @@ mod tests { use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::ToolRequestUserInputResponse; use codex_protocol::approvals::ElicitationAction; - use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::mcp::RequestId as McpRequestId; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; - use codex_protocol::protocol::Op; - use codex_protocol::protocol::ReviewDecision; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -474,7 +479,7 @@ mod tests { .take_resolution(&Op::ExecApproval { id: "approval-1".to_string(), turn_id: None, - decision: ReviewDecision::Approved, + decision: CommandExecutionApprovalDecision::Accept, }) .expect("resolution should serialize") .expect("request should be pending"); @@ -703,7 +708,7 @@ mod tests { } #[test] - fn rejects_invalid_patch_decisions_for_file_change_requests() { + fn resolves_patch_approval_through_app_server_request_id() { let mut pending = PendingAppServerRequests::default(); assert_eq!( pending.note_server_request(&ServerRequest::FileChangeRequestApproval { @@ -719,22 +724,16 @@ mod tests { None ); - let error = pending + let resolution = pending .take_resolution(&Op::PatchApproval { id: "patch-1".to_string(), - decision: ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![ - "echo".to_string(), - "hi".to_string(), - ]), - }, + decision: FileChangeApprovalDecision::Cancel, }) - .expect_err("invalid patch decision should fail"); + .expect("resolution should serialize") + .expect("request should be pending"); - assert_eq!( - error, - "execpolicy amendment is not a valid file change approval decision" - ); + assert_eq!(resolution.request_id, AppServerRequestId::Integer(13)); + assert_eq!(resolution.result, json!({ "decision": "cancel" })); } #[test] diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 559bdfd99c..2a5e01ed28 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -457,7 +457,7 @@ pub(super) async fn fetch_account_rate_limits( .await .wrap_err("account/rateLimits/read failed in TUI")?; - Ok(app_server_rate_limit_snapshots_to_core(response)) + Ok(app_server_rate_limit_snapshots(response)) } pub(super) async fn send_add_credits_nudge_email( diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 3b5479f1d5..18da9a4b05 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -48,7 +48,7 @@ impl App { match self.rebuild_config_for_cwd(resume_cwd.clone()).await { Ok(config) => Ok(config), Err(err) => { - if crate::cwds_differ(current_cwd, &resume_cwd) { + if crate::session_resume::cwds_differ(current_cwd, &resume_cwd) { Err(err) } else { let resume_cwd_display = resume_cwd.display().to_string(); @@ -65,7 +65,7 @@ impl App { pub(super) fn apply_runtime_policy_overrides(&mut self, config: &mut Config) { if let Some(policy) = self.runtime_approval_policy_override.as_ref() - && let Err(err) = config.permissions.approval_policy.set(*policy) + && let Err(err) = config.permissions.approval_policy.set(policy.to_core()) { tracing::warn!(%err, "failed to carry forward approval policy override"); self.chat_widget.add_error_message(format!( @@ -94,7 +94,7 @@ impl App { user_message_prefix: &str, log_message: &str, ) -> bool { - if let Err(err) = config.permissions.approval_policy.set(policy) { + if let Err(err) = config.permissions.approval_policy.set(policy.to_core()) { tracing::warn!(error = %err, "{log_message}"); self.chat_widget .add_error_message(format!("{user_message_prefix}: {err}")); @@ -294,8 +294,9 @@ impl App { self.set_approvals_reviewer_in_app_and_widget(self.config.approvals_reviewer); } if approval_policy_override.is_some() { - self.chat_widget - .set_approval_policy(self.config.permissions.approval_policy.value()); + self.chat_widget.set_approval_policy(AskForApproval::from( + self.config.permissions.approval_policy.value(), + )); } if permission_profile_override.is_some() && let Err(err) = self @@ -351,8 +352,8 @@ impl App { #[cfg(target_os = "windows")] { let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); - self.app_event_tx.send(AppEvent::CodexOp( - AppCommand::override_turn_context( + self.app_event_tx + .send(AppEvent::CodexOp(AppCommand::override_turn_context( /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, @@ -365,9 +366,7 @@ impl App { /*service_tier*/ None, /*collaboration_mode*/ None, /*personality*/ None, - ) - .into_core(), - )); + ))); } } @@ -495,7 +494,7 @@ impl App { (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) } - pub(crate) fn token_usage(&self) -> codex_protocol::protocol::TokenUsage { + pub(crate) fn token_usage(&self) -> crate::token_usage::TokenUsage { self.chat_widget.token_usage() } @@ -548,11 +547,11 @@ mod tests { use super::*; use crate::app::test_support::app_enabled_in_effective_config; use crate::app::test_support::make_test_app; + use crate::chatwidget::test_events::Event; + use crate::chatwidget::test_events::EventMsg; + use crate::chatwidget::test_events::SessionConfiguredEvent; use crate::test_support::PathBufExt; use codex_protocol::models::PermissionProfile; - use codex_protocol::protocol::Event; - use codex_protocol::protocol::EventMsg; - use codex_protocol::protocol::SessionConfiguredEvent; use pretty_assertions::assert_eq; use tempfile::tempdir; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index d4002f2453..9dc39d636a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -313,15 +313,14 @@ impl App { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } AppEvent::CodexOp(op) => { - self.submit_active_thread_op(app_server, op.into()).await?; + self.submit_active_thread_op(app_server, op).await?; } AppEvent::ApproveRecentAutoReviewDenial { thread_id, id } => { self.chat_widget .approve_recent_auto_review_denial(thread_id, id); } AppEvent::SubmitThreadOp { thread_id, op } => { - self.submit_thread_op(app_server, thread_id, op.into()) - .await?; + self.submit_thread_op(app_server, thread_id, op).await?; } AppEvent::ThreadHistoryEntryResponse { thread_id, event } => { self.enqueue_thread_history_entry_response(thread_id, event) @@ -1004,7 +1003,7 @@ impl App { self.app_event_tx.send(AppEvent::CodexOp( AppCommand::override_turn_context( /*cwd*/ None, - Some(preset.approval), + Some(AskForApproval::from(preset.approval)), Some(self.config.approvals_reviewer), Some(preset.permission_profile.clone()), #[cfg(target_os = "windows")] @@ -1018,8 +1017,9 @@ impl App { ) .into(), )); - self.app_event_tx - .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); + self.app_event_tx.send(AppEvent::UpdateAskForApprovalPolicy( + AskForApproval::from(preset.approval), + )); self.app_event_tx.send(AppEvent::UpdatePermissionProfile( preset.permission_profile.clone(), )); @@ -1267,10 +1267,10 @@ impl App { return Ok(AppRunControl::Continue); } self.config = config; - self.runtime_approval_policy_override = - Some(self.config.permissions.approval_policy.value()); - self.chat_widget - .set_approval_policy(self.config.permissions.approval_policy.value()); + let approval_policy = + AskForApproval::from(self.config.permissions.approval_policy.value()); + self.runtime_approval_policy_override = Some(approval_policy); + self.chat_widget.set_approval_policy(approval_policy); self.sync_active_thread_permission_settings_to_cached_session() .await; } diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index b665361387..0e1209feac 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -14,10 +14,8 @@ //! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are //! found. The primary thread itself is never included in the output. -use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_protocol::ThreadId; -use codex_protocol::protocol::SubAgentSource; use std::collections::HashMap; use std::collections::HashSet; @@ -63,15 +61,12 @@ pub(crate) fn find_loaded_subagent_threads_for_primary( continue; } - let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: source_parent_thread_id, - .. - }) = &thread.source + let Some(source_parent_thread_id) = thread_spawn_parent_thread_id(&thread.source) else { continue; }; - if *source_parent_thread_id != parent_thread_id { + if source_parent_thread_id != parent_thread_id { continue; } @@ -96,6 +91,18 @@ pub(crate) fn find_loaded_subagent_threads_for_primary( loaded_threads } +fn thread_spawn_parent_thread_id( + source: &codex_app_server_protocol::SessionSource, +) -> Option { + let value = serde_json::to_value(source).ok()?; + let parent_thread_id = value + .get("subAgent")? + .get("thread_spawn")? + .get("parent_thread_id")? + .as_str()?; + ThreadId::from_string(parent_thread_id).ok() +} + #[cfg(test)] mod tests { use super::LoadedSubagentThread; @@ -104,7 +111,6 @@ mod tests { use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadStatus; use codex_protocol::ThreadId; - use codex_protocol::protocol::SubAgentSource; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -131,6 +137,25 @@ mod tests { } } + fn thread_spawn_source( + parent_thread_id: ThreadId, + depth: i32, + agent_nickname: &str, + agent_role: &str, + ) -> SessionSource { + serde_json::from_value(serde_json::json!({ + "subAgent": { + "thread_spawn": { + "parent_thread_id": parent_thread_id.to_string(), + "depth": depth, + "agent_nickname": agent_nickname, + "agent_role": agent_role, + } + } + })) + .expect("valid subagent source") + } + #[test] fn finds_loaded_subagent_tree_for_primary_thread() { let primary_thread_id = @@ -146,39 +171,21 @@ mod tests { let mut child = test_thread( child_thread_id, - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: primary_thread_id, - depth: 1, - agent_path: None, - agent_nickname: Some("Scout".to_string()), - agent_role: Some("explorer".to_string()), - }), + thread_spawn_source(primary_thread_id, 1, "Scout", "explorer"), ); child.agent_nickname = Some("Scout".to_string()); child.agent_role = Some("explorer".to_string()); let mut grandchild = test_thread( grandchild_thread_id, - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: child_thread_id, - depth: 2, - agent_path: None, - agent_nickname: Some("Atlas".to_string()), - agent_role: Some("worker".to_string()), - }), + thread_spawn_source(child_thread_id, 2, "Atlas", "worker"), ); grandchild.agent_nickname = Some("Atlas".to_string()); grandchild.agent_role = Some("worker".to_string()); let unrelated_child = test_thread( unrelated_child_id, - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: unrelated_parent_id, - depth: 1, - agent_path: None, - agent_nickname: Some("Other".to_string()), - agent_role: Some("researcher".to_string()), - }), + thread_spawn_source(unrelated_parent_id, 1, "Other", "researcher"), ); let loaded = find_loaded_subagent_threads_for_primary( diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index d3963e3d48..3c7c6be9fd 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -1,5 +1,4 @@ use crate::app_command::AppCommand; -use crate::app_command::AppCommandView; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -77,13 +76,13 @@ impl PendingInteractiveReplayState { { let op: AppCommand = op.into(); matches!( - op.view(), - AppCommandView::ExecApproval { .. } - | AppCommandView::PatchApproval { .. } - | AppCommandView::ResolveElicitation { .. } - | AppCommandView::RequestPermissionsResponse { .. } - | AppCommandView::UserInputAnswer { .. } - | AppCommandView::Shutdown + &op, + AppCommand::ExecApproval { .. } + | AppCommand::PatchApproval { .. } + | AppCommand::ResolveElicitation { .. } + | AppCommand::RequestPermissionsResponse { .. } + | AppCommand::UserInputAnswer { .. } + | AppCommand::Shutdown ) } @@ -92,8 +91,8 @@ impl PendingInteractiveReplayState { T: Into, { let op: AppCommand = op.into(); - match op.view() { - AppCommandView::ExecApproval { id, turn_id, .. } => { + match &op { + AppCommand::ExecApproval { id, turn_id, .. } => { self.exec_approval_call_ids.remove(id); if let Some(turn_id) = turn_id { Self::remove_call_id_from_turn_map_entry( @@ -105,7 +104,7 @@ impl PendingInteractiveReplayState { self.pending_requests_by_request_id .retain(|_, pending| !matches!(pending, PendingInteractiveRequest::ExecApproval { approval_id, .. } if approval_id == id)); } - AppCommandView::PatchApproval { id, .. } => { + AppCommand::PatchApproval { id, .. } => { self.patch_approval_call_ids.remove(id); Self::remove_call_id_from_turn_map( &mut self.patch_approval_call_ids_by_turn_id, @@ -114,7 +113,7 @@ impl PendingInteractiveReplayState { self.pending_requests_by_request_id .retain(|_, pending| !matches!(pending, PendingInteractiveRequest::PatchApproval { item_id, .. } if item_id == id)); } - AppCommandView::ResolveElicitation { + AppCommand::ResolveElicitation { server_name, request_id, .. @@ -130,7 +129,7 @@ impl PendingInteractiveReplayState { }, ); } - AppCommandView::RequestPermissionsResponse { id, .. } => { + AppCommand::RequestPermissionsResponse { id, .. } => { self.request_permissions_call_ids.remove(id); Self::remove_call_id_from_turn_map( &mut self.request_permissions_call_ids_by_turn_id, @@ -145,7 +144,7 @@ impl PendingInteractiveReplayState { // `Op::UserInputAnswer` identifies the turn, not the prompt call_id. The UI // answers queued prompts for the same turn in FIFO order, so remove the oldest // queued call_id for that turn. - AppCommandView::UserInputAnswer { id, .. } => { + AppCommand::UserInputAnswer { id, .. } => { let mut remove_turn_entry = false; if let Some(call_ids) = self.request_user_input_call_ids_by_turn_id.get_mut(id) { if !call_ids.is_empty() { @@ -165,7 +164,7 @@ impl PendingInteractiveReplayState { self.request_user_input_call_ids_by_turn_id.remove(id); } } - AppCommandView::Shutdown => self.clear(), + AppCommand::Shutdown => self.clear(), _ => {} } } @@ -579,6 +578,8 @@ fn app_server_request_id_to_mcp_request_id( mod tests { use super::super::ThreadBufferedEvent; use super::super::ThreadEventStore; + use crate::app_command::AppCommand as Op; + use crate::approval_display::ReviewDecision; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::McpElicitationObjectType; @@ -594,8 +595,6 @@ mod tests { use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStatus; - use codex_protocol::protocol::Op; - use codex_protocol::protocol::ReviewDecision; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -767,7 +766,7 @@ mod tests { store.note_outbound_op(&Op::ExecApproval { id: "approval-1".to_string(), turn_id: Some("turn-1".to_string()), - decision: ReviewDecision::Approved, + decision: ReviewDecision::Approved.into(), }); let snapshot = store.snapshot(); @@ -854,7 +853,7 @@ mod tests { store.note_outbound_op(&Op::PatchApproval { id: "call-1".to_string(), - decision: ReviewDecision::Approved, + decision: codex_app_server_protocol::FileChangeApprovalDecision::Accept, }); let snapshot = store.snapshot(); @@ -920,7 +919,7 @@ mod tests { store.note_outbound_op(&Op::ExecApproval { id: "call-1".to_string(), turn_id: Some("turn-1".to_string()), - decision: ReviewDecision::Approved, + decision: ReviewDecision::Approved.into(), }); assert_eq!(store.has_pending_thread_approvals(), false); diff --git a/codex-rs/tui/src/app/platform_actions.rs b/codex-rs/tui/src/app/platform_actions.rs index 415318a3ed..d717184327 100644 --- a/codex-rs/tui/src/app/platform_actions.rs +++ b/codex-rs/tui/src/app/platform_actions.rs @@ -28,6 +28,7 @@ impl App { tokio::task::spawn_blocking(move || { let logs_base_dir_path = logs_base_dir.as_path(); + let sandbox_policy = sandbox_policy.to_core(); let result = codex_windows_sandbox::apply_world_writable_scan_and_denies( logs_base_dir_path, cwd.as_path(), diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index c51863bd16..4eded21f53 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -636,7 +636,7 @@ impl App { let resume_cwd = if self.remote_app_server_url.is_some() { current_cwd.clone() } else { - match crate::resolve_cwd_for_resume_or_fork( + match crate::session_resume::resolve_cwd_for_resume_or_fork( tui, &self.config, ¤t_cwd, @@ -647,9 +647,9 @@ impl App { ) .await? { - crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, - crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), - crate::ResolveCwdOutcome::Exit => { + crate::session_resume::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, + crate::session_resume::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), + crate::session_resume::ResolveCwdOutcome::Exit => { return Ok(AppRunControl::Exit(ExitReason::UserRequested)); } } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index fd88161cad..18f60583e8 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -74,7 +74,8 @@ fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { "test_originator".to_string(), /*log_user_prompts*/ false, "test".to_string(), - SessionSource::Cli, + serde_json::from_value(serde_json::json!("cli")) + .expect("cli session source should deserialize"), ) } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 29f82ab85d..305644d636 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -21,6 +21,11 @@ use crate::history_cell::new_session_info; use crate::multi_agents::AgentPickerThreadEntry; use assert_matches::assert_matches; +use crate::app_command::AppCommand as Op; +use crate::chatwidget::test_events::Event; +use crate::chatwidget::test_events::EventMsg; +use crate::chatwidget::test_events::SessionConfiguredEvent; +use crate::diff_model::FileChange; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::TerminalResizeReflowMaxRows; @@ -28,6 +33,7 @@ use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; use codex_app_server_protocol::AgentMessageDeltaNotification; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -46,6 +52,7 @@ use codex_app_server_protocol::PermissionsRequestApprovalParams; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItem; @@ -67,21 +74,9 @@ 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::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; 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::FileChange; -use codex_protocol::protocol::NetworkApprovalContext; -use codex_protocol::protocol::NetworkApprovalProtocol; -use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::RolloutLine; -use codex_protocol::protocol::SessionConfiguredEvent; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::TurnContextItem; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; @@ -104,6 +99,31 @@ macro_rules! assert_app_snapshot { }; } +fn run_large_stack_test(body: impl FnOnce() -> T + Send + 'static) -> T +where + T: Send + 'static, +{ + std::thread::Builder::new() + .name("codex-tui-app-test".to_string()) + .stack_size(8 * 1024 * 1024) + .spawn(body) + .expect("spawn large-stack test thread") + .join() + .expect("large-stack test thread panicked") +} + +macro_rules! run_large_stack_async_test { + ($body:block) => { + run_large_stack_test(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async $body) + }) + }; +} + fn test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") } @@ -513,8 +533,7 @@ async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()> &Op::GetHistoryEntryRequest { offset: 0, log_id: 1, - } - .into(), + }, ) .await?; @@ -1224,196 +1243,217 @@ async fn token_usage_update_refreshes_status_line_with_runtime_context_window() ); } -#[tokio::test] -async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let thread_id = ThreadId::new(); - app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); +#[test] +fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> { + run_large_stack_async_test!({ + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); - app.open_agent_picker(&mut app_server).await; + app.open_agent_picker(&mut app_server).await; - assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); - assert_eq!( - app.agent_navigation.get(&thread_id), - Some(&AgentPickerThreadEntry { - agent_nickname: None, - agent_role: None, - is_closed: true, - }) - ); - assert_eq!(app.agent_navigation.ordered_thread_ids(), vec![thread_id]); - Ok(()) + assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); + assert_eq!( + app.agent_navigation.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: None, + agent_role: None, + is_closed: true, + }) + ); + assert_eq!(app.agent_navigation.ordered_thread_ids(), vec![thread_id]); + Ok(()) + }) } -#[tokio::test] -async fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let thread_id = ThreadId::new(); - app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); - app.agent_navigation.upsert( - thread_id, - Some("Robie".to_string()), - Some("explorer".to_string()), - /*is_closed*/ true, - ); +#[test] +fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Result<()> { + run_large_stack_async_test!({ + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); + app.agent_navigation.upsert( + thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + /*is_closed*/ true, + ); - app.open_agent_picker(&mut app_server).await; + app.open_agent_picker(&mut app_server).await; - assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); - assert_eq!( - app.agent_navigation.get(&thread_id), - Some(&AgentPickerThreadEntry { - agent_nickname: Some("Robie".to_string()), - agent_role: Some("explorer".to_string()), - is_closed: true, - }) - ); - Ok(()) + assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); + assert_eq!( + app.agent_navigation.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + is_closed: true, + }) + ); + Ok(()) + }) } -#[tokio::test] -async fn open_agent_picker_prunes_terminal_metadata_only_threads() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let thread_id = ThreadId::new(); - app.agent_navigation.upsert( - thread_id, - Some("Ghost".to_string()), - Some("worker".to_string()), - /*is_closed*/ false, - ); +#[test] +fn open_agent_picker_prunes_terminal_metadata_only_threads() -> Result<()> { + run_large_stack_async_test!({ + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + app.agent_navigation.upsert( + thread_id, + Some("Ghost".to_string()), + Some("worker".to_string()), + /*is_closed*/ false, + ); - app.open_agent_picker(&mut app_server).await; + app.open_agent_picker(&mut app_server).await; - assert_eq!(app.agent_navigation.get(&thread_id), None); - assert!(app.agent_navigation.is_empty()); - Ok(()) + assert_eq!(app.agent_navigation.get(&thread_id), None); + assert!(app.agent_navigation.is_empty()); + Ok(()) + }) } -#[tokio::test] -async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let thread_id = ThreadId::new(); - app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); - app.agent_navigation.upsert( - thread_id, - Some("Robie".to_string()), - Some("explorer".to_string()), - /*is_closed*/ false, - ); +#[test] +fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> { + run_large_stack_async_test!({ + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); + app.agent_navigation.upsert( + thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + /*is_closed*/ false, + ); - app.open_agent_picker(&mut app_server).await; + app.open_agent_picker(&mut app_server).await; - assert_eq!( - app.agent_navigation.get(&thread_id), - Some(&AgentPickerThreadEntry { - agent_nickname: Some("Robie".to_string()), - agent_role: Some("explorer".to_string()), - is_closed: true, - }) - ); - Ok(()) + assert_eq!( + app.agent_navigation.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + is_closed: true, + }) + ); + Ok(()) + }) } -#[tokio::test] -async fn open_agent_picker_marks_loaded_threads_open() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let started = app_server - .start_thread(app.chat_widget.config_ref()) - .await?; - let thread_id = started.session.thread_id; - app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); +#[test] +fn open_agent_picker_marks_loaded_threads_open() -> Result<()> { + run_large_stack_async_test!({ + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let thread_id = started.session.thread_id; + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); - app.open_agent_picker(&mut app_server).await; + app.open_agent_picker(&mut app_server).await; - assert_eq!( - app.agent_navigation.get(&thread_id), - Some(&AgentPickerThreadEntry { - agent_nickname: None, - agent_role: None, - is_closed: false, - }) - ); - Ok(()) + assert_eq!( + app.agent_navigation.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: None, + agent_role: None, + is_closed: false, + }) + ); + Ok(()) + }) } -#[tokio::test] -async fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() --> Result<()> { - let mut app = make_test_app().await; - let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let started = app_server - .start_thread(app.chat_widget.config_ref()) - .await?; - let thread_id = started.session.thread_id; - app.agent_navigation.upsert( - thread_id, - Some("Scout".to_string()), - Some("worker".to_string()), - /*is_closed*/ false, - ); +#[test] +fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() -> Result<()> { + run_large_stack_async_test!({ + let config = { + let app = make_test_app().await; + app.chat_widget.config_ref().clone() + }; + let mut app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("embedded app server"); + let started = app_server.start_thread(&config).await?; + let thread_id = started.session.thread_id; + let mut app = make_test_app().await; + app.agent_navigation.upsert( + thread_id, + Some("Scout".to_string()), + Some("worker".to_string()), + /*is_closed*/ false, + ); - let err = app - .attach_live_thread_for_selection(&mut app_server, thread_id) - .await - .expect_err("empty fallback should not attach as a blank replay-only thread"); + let err = app + .attach_live_thread_for_selection(&mut app_server, thread_id) + .await + .expect_err("empty fallback should not attach as a blank replay-only thread"); - assert_eq!( - err.to_string(), - format!("Agent thread {thread_id} is not yet available for replay or live attach.") - ); - assert!(!app.thread_event_channels.contains_key(&thread_id)); - Ok(()) + assert_eq!( + err.to_string(), + format!("Agent thread {thread_id} is not yet available for replay or live attach.") + ); + assert!(!app.thread_event_channels.contains_key(&thread_id)); + Ok(()) + }) } -#[tokio::test] -async fn attach_live_thread_for_selection_rejects_unmaterialized_fallback_threads() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let mut ephemeral_config = app.chat_widget.config_ref().clone(); - ephemeral_config.ephemeral = true; - let started = app_server.start_thread(&ephemeral_config).await?; - let thread_id = started.session.thread_id; - app.agent_navigation.upsert( - thread_id, - Some("Scout".to_string()), - Some("worker".to_string()), - /*is_closed*/ false, - ); +#[test] +fn attach_live_thread_for_selection_rejects_unmaterialized_fallback_threads() -> Result<()> { + run_large_stack_async_test!({ + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let mut ephemeral_config = app.chat_widget.config_ref().clone(); + ephemeral_config.ephemeral = true; + let started = app_server.start_thread(&ephemeral_config).await?; + let thread_id = started.session.thread_id; + app.agent_navigation.upsert( + thread_id, + Some("Scout".to_string()), + Some("worker".to_string()), + /*is_closed*/ false, + ); - let err = app - .attach_live_thread_for_selection(&mut app_server, thread_id) - .await - .expect_err("ephemeral fallback should not attach as a blank live thread"); + let err = app + .attach_live_thread_for_selection(&mut app_server, thread_id) + .await + .expect_err("ephemeral fallback should not attach as a blank live thread"); - assert_eq!( - err.to_string(), - format!("Agent thread {thread_id} is not yet available for replay or live attach.") - ); - assert!(!app.thread_event_channels.contains_key(&thread_id)); - Ok(()) + assert_eq!( + err.to_string(), + format!("Agent thread {thread_id} is not yet available for replay or live attach.") + ); + assert!(!app.thread_event_channels.contains_key(&thread_id)); + Ok(()) + }) } #[tokio::test] @@ -1442,58 +1482,64 @@ async fn should_attach_live_thread_for_selection_skips_closed_metadata_only_thre assert!(!app.should_attach_live_thread_for_selection(thread_id)); } -#[tokio::test] -async fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_threads() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let thread_id = ThreadId::new(); - app.agent_navigation.upsert( - thread_id, - Some("Ghost".to_string()), - Some("worker".to_string()), - /*is_closed*/ false, - ); +#[test] +fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_threads() -> Result<()> { + run_large_stack_async_test!({ + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + app.agent_navigation.upsert( + thread_id, + Some("Ghost".to_string()), + Some("worker".to_string()), + /*is_closed*/ false, + ); - let is_available = app - .refresh_agent_picker_thread_liveness(&mut app_server, thread_id) - .await; + let is_available = app + .refresh_agent_picker_thread_liveness(&mut app_server, thread_id) + .await; - assert!(!is_available); - assert_eq!(app.agent_navigation.get(&thread_id), None); - assert!(!app.thread_event_channels.contains_key(&thread_id)); - Ok(()) + assert!(!is_available); + assert_eq!(app.agent_navigation.get(&thread_id), None); + assert!(!app.thread_event_channels.contains_key(&thread_id)); + Ok(()) + }) } -#[tokio::test] -async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> { - let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let _ = app.config.features.disable(Feature::Collab); +#[test] +fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> { + run_large_stack_async_test!({ + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let _ = app.config.features.disable(Feature::Collab); - app.open_agent_picker(&mut app_server).await; - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.open_agent_picker(&mut app_server).await; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!( - app_event_rx.try_recv(), - Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)] - ); - let cell = match app_event_rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => cell, - other => panic!("expected InsertHistoryCell event, got {other:?}"), - }; - let rendered = cell - .display_lines(/*width*/ 120) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(rendered.contains("Subagents will be enabled in the next session.")); - Ok(()) + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)] + ); + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = cell + .display_lines(/*width*/ 120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(rendered.contains("Subagents will be enabled in the next session.")); + Ok(()) + }) } #[tokio::test] @@ -1625,15 +1671,17 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< auto_review.approvals_reviewer ); assert_eq!( - app.config.permissions.approval_policy.value(), + AskForApproval::from(app.config.permissions.approval_policy.value()), auto_review.approval_policy ); assert_eq!( - app.chat_widget - .config_ref() - .permissions - .approval_policy - .value(), + AskForApproval::from( + app.chat_widget + .config_ref() + .permissions + .approval_policy + .value(), + ), auto_review.approval_policy ); assert_eq!( @@ -1658,7 +1706,6 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - sandbox_policy: None, permission_profile: Some(auto_review.permission_profile.clone()), windows_sandbox_level: None, model: None, @@ -1714,7 +1761,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor app.config .permissions .approval_policy - .set(AskForApproval::OnRequest)?; + .set(AskForApproval::OnRequest.to_core())?; app.config .permissions .set_permission_profile(PermissionProfile::workspace_write())?; @@ -1735,7 +1782,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor ); assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User); assert_eq!( - app.config.permissions.approval_policy.value(), + AskForApproval::from(app.config.permissions.approval_policy.value()), AskForApproval::OnRequest ); assert_eq!( @@ -1749,7 +1796,6 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), - sandbox_policy: None, permission_profile: None, windows_sandbox_level: None, model: None, @@ -1812,7 +1858,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review auto_review.approvals_reviewer ); assert_eq!( - app.config.permissions.approval_policy.value(), + AskForApproval::from(app.config.permissions.approval_policy.value()), auto_review.approval_policy ); assert_eq!( @@ -1828,7 +1874,6 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - sandbox_policy: None, permission_profile: Some(auto_review.permission_profile.clone()), windows_sandbox_level: None, model: None, @@ -1886,7 +1931,6 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), - sandbox_policy: None, permission_profile: None, windows_sandbox_level: None, model: None, @@ -1946,7 +1990,6 @@ async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_rev cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - sandbox_policy: None, permission_profile: Some(auto_review.permission_profile.clone()), windows_sandbox_level: None, model: None, @@ -2034,7 +2077,6 @@ guardian_approval = true cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), - sandbox_policy: None, permission_profile: None, windows_sandbox_level: None, model: None, @@ -2464,35 +2506,37 @@ async fn inactive_thread_exec_approval_preserves_context() { assert_eq!( network_approval_context, - Some(NetworkApprovalContext { + Some(AppServerNetworkApprovalContext { host: "example.com".to_string(), - protocol: NetworkApprovalProtocol::Socks5Tcp, + protocol: AppServerNetworkApprovalProtocol::Socks5Tcp, }) ); assert_eq!( additional_permissions, - Some(CoreAdditionalPermissionProfile { - network: Some(NetworkPermissions { + Some(AdditionalPermissionProfile { + network: Some(AdditionalNetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions::from_read_write_roots( - Some(vec![test_absolute_path("/tmp/read-only")]), - Some(vec![test_absolute_path("/tmp/write")]), - )), + file_system: Some(AdditionalFileSystemPermissions { + read: Some(vec![test_absolute_path("/tmp/read-only")]), + write: Some(vec![test_absolute_path("/tmp/write")]), + glob_scan_max_depth: None, + entries: None, + }), }) ); assert_eq!( available_decisions, vec![ - codex_protocol::protocol::ReviewDecision::Approved, - codex_protocol::protocol::ReviewDecision::ApprovedForSession, - codex_protocol::protocol::ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { + codex_app_server_protocol::CommandExecutionApprovalDecision::Accept, + codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession, + codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment: AppServerNetworkPolicyAmendment { host: "example.com".to_string(), - action: codex_protocol::approvals::NetworkPolicyRuleAction::Allow, + action: AppServerNetworkPolicyRuleAction::Allow, }, }, - codex_protocol::protocol::ReviewDecision::Abort, + codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel, ] ); } @@ -2737,35 +2781,14 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re ); let rollout_path = temp_dir.path().join("agent-rollout.jsonl"); - let permission_profile = PermissionProfile::workspace_write(); - let turn_context = TurnContextItem { - turn_id: None, - trace_id: None, - cwd: test_path_buf("/tmp/agent"), - current_date: None, - timezone: None, - approval_policy: primary_session.approval_policy, - sandbox_policy: permission_profile - .to_legacy_sandbox_policy(test_path_buf("/tmp/agent").as_path()) - .expect("workspace profile must be legacy-compatible"), - permission_profile: Some(permission_profile), - network: None, - file_system_sandbox_policy: None, - model: "gpt-agent".to_string(), - personality: None, - collaboration_mode: None, - realtime_active: Some(false), - effort: primary_session.reasoning_effort, - summary: app.config.model_reasoning_summary.unwrap_or_default(), - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, - }; - let rollout = RolloutLine { - timestamp: "t0".to_string(), - item: RolloutItem::TurnContext(turn_context), - }; + let rollout = serde_json::json!({ + "timestamp": "t0", + "type": "turn_context", + "payload": { + "cwd": test_path_buf("/tmp/agent"), + "model": "gpt-agent", + }, + }); std::fs::write( &rollout_path, format!("{}\n", serde_json::to_string(&rollout)?), @@ -3352,30 +3375,33 @@ async fn side_discard_selection_keeps_current_side_thread() { #[tokio::test] async fn discard_side_thread_removes_agent_navigation_entry() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; - let mut side_config = app.chat_widget.config_ref().clone(); - side_config.ephemeral = true; - let started = app_server.start_thread(&side_config).await?; - let side_thread_id = started.session.thread_id; - app.side_threads - .insert(side_thread_id, SideThreadState::new(ThreadId::new())); - app.agent_navigation.upsert( - side_thread_id, - Some("Side".to_string()), - Some("side".to_string()), - /*is_closed*/ false, - ); + Box::pin(async { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let mut side_config = app.chat_widget.config_ref().clone(); + side_config.ephemeral = true; + let started = app_server.start_thread(&side_config).await?; + let side_thread_id = started.session.thread_id; + app.side_threads + .insert(side_thread_id, SideThreadState::new(ThreadId::new())); + app.agent_navigation.upsert( + side_thread_id, + Some("Side".to_string()), + Some("side".to_string()), + /*is_closed*/ false, + ); - assert!( - app.discard_side_thread(&mut app_server, side_thread_id) - .await - ); + assert!( + app.discard_side_thread(&mut app_server, side_thread_id) + .await + ); - assert_eq!(app.agent_navigation.get(&side_thread_id), None); - assert!(!app.side_threads.contains_key(&side_thread_id)); - Ok(()) + assert_eq!(app.agent_navigation.get(&side_thread_id), None); + assert!(!app.side_threads.contains_key(&side_thread_id)); + Ok(()) + }) + .await } #[tokio::test] @@ -3560,9 +3586,10 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { )) as Arc }; let make_header = |is_first| -> Arc { - let event = SessionConfiguredEvent { - session_id: ThreadId::new(), + let session = ThreadSessionState { + thread_id: ThreadId::new(), forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), @@ -3571,17 +3598,17 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/tmp/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::High), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; Arc::new(new_session_info( app.chat_widget.config_ref(), app.chat_widget.current_model(), - event, + &session, is_first, /*tooltip_override*/ None, /*auth_plan*/ None, @@ -4208,7 +4235,7 @@ fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { "test_originator".to_string(), /*log_user_prompts*/ false, "test".to_string(), - SessionSource::Cli, + crate::test_support::session_source_cli(), ) } @@ -4311,9 +4338,10 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { }; let make_header = |is_first| { - let event = SessionConfiguredEvent { - session_id: ThreadId::new(), + let session = ThreadSessionState { + thread_id: ThreadId::new(), forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), @@ -4322,17 +4350,17 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: None, history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; Arc::new(new_session_info( app.chat_widget.config_ref(), app.chat_widget.current_model(), - event, + &session, is_first, /*tooltip_override*/ None, /*auth_plan*/ None, @@ -4830,7 +4858,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { path: None, cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0".to_string(), - source: SessionSource::Cli.into(), + source: SessionSource::Cli, agent_nickname: None, agent_role: None, git_info: None, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 10fc370f58..327f806c56 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -19,7 +19,7 @@ pub(super) struct ThreadEventSnapshot { pub(super) enum ThreadBufferedEvent { Notification(ServerNotification), Request(ServerRequest), - HistoryEntryResponse(GetHistoryEntryResponseEvent), + HistoryEntryResponse(HistoryLookupResponse), FeedbackSubmission(FeedbackThreadEvent), } @@ -318,6 +318,7 @@ mod tests { use super::*; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; + use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::HookCompletedNotification; use codex_app_server_protocol::HookEventName as AppServerHookEventName; @@ -334,7 +335,6 @@ mod tests { use codex_app_server_protocol::TurnStartedNotification; use codex_config::types::ApprovalsReviewer; use codex_protocol::models::PermissionProfile; - use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; use std::path::PathBuf; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 7538209cd4..ebf9a1d854 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -5,6 +5,7 @@ //! when the visible thread changes. use super::*; +use crate::session_resume::read_session_model; impl App { pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) { @@ -213,24 +214,11 @@ impl App { let thread_label = Some(self.thread_label(thread_id)); match request { ServerRequest::CommandExecutionRequestApproval { params, .. } => { - let network_approval_context = params - .network_approval_context - .clone() - .map(network_approval_context_to_core); - let additional_permissions = params.additional_permissions.clone().map(Into::into); - let proposed_execpolicy_amendment = params - .proposed_execpolicy_amendment - .clone() - .map(codex_app_server_protocol::ExecPolicyAmendment::into_core); - let proposed_network_policy_amendments = params - .proposed_network_policy_amendments - .clone() - .map(|amendments| { - amendments - .into_iter() - .map(codex_app_server_protocol::NetworkPolicyAmendment::into_core) - .collect::>() - }); + let network_approval_context = params.network_approval_context.clone(); + let additional_permissions = params.additional_permissions.clone(); + let proposed_execpolicy_amendment = params.proposed_execpolicy_amendment.clone(); + let proposed_network_policy_amendments = + params.proposed_network_policy_amendments.clone(); Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec { thread_id, thread_label, @@ -244,23 +232,14 @@ impl App { .map(split_command_string) .unwrap_or_default(), reason: params.reason.clone(), - available_decisions: params - .available_decisions - .clone() - .map(|decisions| { - decisions - .into_iter() - .map(command_execution_decision_to_review_decision) - .collect() - }) - .unwrap_or_else(|| { - default_exec_approval_decisions( - network_approval_context.as_ref(), - proposed_execpolicy_amendment.as_ref(), - proposed_network_policy_amendments.as_deref(), - additional_permissions.as_ref(), - ) - }), + available_decisions: params.available_decisions.clone().unwrap_or_else(|| { + default_exec_approval_decisions( + network_approval_context.as_ref(), + proposed_execpolicy_amendment.as_ref(), + proposed_network_policy_amendments.as_deref(), + additional_permissions.as_ref(), + ) + }), network_approval_context, additional_permissions, })) @@ -457,9 +436,9 @@ impl App { thread_id: ThreadId, op: &AppCommand, ) -> Result { - match op.view() { - AppCommandView::Other(Op::AddToHistory { text }) => { - let text = text.clone(); + match op { + AppCommand::AddToHistory { text } => { + let text = text.to_string(); let config = self.chat_widget.config_ref().clone(); tokio::spawn(async move { if let Err(err) = append_message_history_entry(&text, &thread_id, &config).await @@ -473,11 +452,11 @@ impl App { }); Ok(true) } - AppCommandView::Other(Op::GetHistoryEntryRequest { offset, log_id }) => { - let offset = *offset; - let log_id = *log_id; + AppCommand::GetHistoryEntryRequest { offset, log_id } => { let config = self.chat_widget.config_ref().clone(); let app_event_tx = self.app_event_tx.clone(); + let offset = *offset; + let log_id = *log_id; tokio::spawn(async move { let entry_opt = tokio::task::spawn_blocking(move || { lookup_message_history_entry(log_id, offset, &config) @@ -490,7 +469,7 @@ impl App { app_event_tx.send(AppEvent::ThreadHistoryEntryResponse { thread_id, - event: GetHistoryEntryResponseEvent { + event: HistoryLookupResponse { offset, log_id, entry: entry_opt.map(|entry| { @@ -515,8 +494,8 @@ impl App { thread_id: ThreadId, op: &AppCommand, ) -> Result { - match op.view() { - AppCommandView::Interrupt => { + match op { + AppCommand::Interrupt => { if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { app_server.turn_interrupt(thread_id, turn_id).await?; } else { @@ -524,7 +503,7 @@ impl App { } Ok(true) } - AppCommandView::UserTurn { + AppCommand::UserTurn { items, cwd, approval_policy, @@ -609,12 +588,12 @@ impl App { thread_id, items.to_vec(), cwd.clone(), - approval_policy, - approvals_reviewer + *approval_policy, + (*approvals_reviewer) .unwrap_or(self.chat_widget.config_ref().approvals_reviewer), permission_profile.clone(), model.to_string(), - effort, + *effort, *summary, *service_tier, collaboration_mode.clone(), @@ -625,12 +604,12 @@ impl App { } Ok(true) } - AppCommandView::ListSkills { cwds, force_reload } => { + AppCommand::ListSkills { cwds, force_reload } => { self.handle_skills_list_result( app_server .skills_list(codex_app_server_protocol::SkillsListParams { - cwds: cwds.to_vec(), - force_reload, + cwds: cwds.clone(), + force_reload: *force_reload, per_cwd_extra_user_roots: None, }) .await, @@ -638,75 +617,67 @@ impl App { ); Ok(true) } - AppCommandView::Compact => { + AppCommand::Compact => { app_server.thread_compact_start(thread_id).await?; Ok(true) } - AppCommandView::SetThreadName { name } => { + AppCommand::SetThreadName { name } => { app_server .thread_set_name(thread_id, name.to_string()) .await?; Ok(true) } - AppCommandView::ThreadRollback { num_turns } => { - let response = match app_server.thread_rollback(thread_id, num_turns).await { + AppCommand::ThreadRollback { num_turns } => { + let response = match app_server.thread_rollback(thread_id, *num_turns).await { Ok(response) => response, Err(err) => { self.handle_backtrack_rollback_failed(); return Err(err); } }; - self.handle_thread_rollback_response(thread_id, num_turns, &response) + self.handle_thread_rollback_response(thread_id, *num_turns, &response) .await; Ok(true) } - AppCommandView::Review { review_request } => { - app_server - .review_start(thread_id, review_request.clone()) - .await?; + AppCommand::Review { target } => { + app_server.review_start(thread_id, target.clone()).await?; Ok(true) } - AppCommandView::CleanBackgroundTerminals => { + AppCommand::CleanBackgroundTerminals => { app_server .thread_background_terminals_clean(thread_id) .await?; Ok(true) } - AppCommandView::RealtimeConversationStart(params) => { + AppCommand::RealtimeConversationStart { transport, voice } => { app_server - .thread_realtime_start(thread_id, params.clone()) + .thread_realtime_start(thread_id, transport.clone(), voice.clone()) .await?; Ok(true) } - AppCommandView::RealtimeConversationAudio(params) => { + AppCommand::RealtimeConversationAudio(frame) => { app_server - .thread_realtime_audio(thread_id, params.clone()) + .thread_realtime_audio(thread_id, frame.clone()) .await?; Ok(true) } - AppCommandView::RealtimeConversationText(params) => { - app_server - .thread_realtime_text(thread_id, params.clone()) - .await?; - Ok(true) - } - AppCommandView::RealtimeConversationClose => { + AppCommand::RealtimeConversationClose => { app_server.thread_realtime_stop(thread_id).await?; Ok(true) } - AppCommandView::RunUserShellCommand { command } => { + AppCommand::RunUserShellCommand { command } => { app_server .thread_shell_command(thread_id, command.to_string()) .await?; Ok(true) } - AppCommandView::ReloadUserConfig => { + AppCommand::ReloadUserConfig => { app_server.reload_user_config().await?; self.refresh_in_memory_config_from_disk().await?; Ok(true) } - AppCommandView::OverrideTurnContext { .. } => Ok(true), - AppCommandView::Other(Op::ApproveGuardianDeniedAction { event }) => { + AppCommand::OverrideTurnContext { .. } => Ok(true), + AppCommand::ApproveGuardianDeniedAction { event } => { app_server .thread_approve_guardian_denied_action(thread_id, event) .await?; @@ -1006,7 +977,7 @@ impl App { pub(super) async fn enqueue_thread_history_entry_response( &mut self, thread_id: ThreadId, - event: GetHistoryEntryResponseEvent, + event: HistoryLookupResponse, ) -> Result<()> { let (sender, store) = { let channel = self.ensure_thread_channel(thread_id); @@ -1317,7 +1288,6 @@ impl App { #[allow(clippy::too_many_arguments)] pub(super) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { - let response = list_skills_response_to_core(response); let cwd = self.chat_widget.config_ref().cwd.clone(); let errors = errors_for_cwd(&cwd, &response); emit_skill_load_warnings(&self.app_event_tx, &errors); diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 9b6d7e9726..d93a6988d1 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -1,6 +1,7 @@ use super::App; -use crate::app_server_session::ThreadSessionState; -use crate::read_session_model; +use crate::session_resume::read_session_model; +use crate::session_state::ThreadSessionState; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::Thread; use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; @@ -11,7 +12,7 @@ impl App { return; }; - let approval_policy = self.config.permissions.approval_policy.value(); + let approval_policy = AskForApproval::from(self.config.permissions.approval_policy.value()); let approvals_reviewer = self.config.approvals_reviewer; let permission_profile = self .chat_widget @@ -55,7 +56,9 @@ impl App { model: self.chat_widget.current_model().to_string(), model_provider_id: self.config.model_provider_id.clone(), service_tier: self.chat_widget.current_service_tier(), - approval_policy: self.config.permissions.approval_policy.value(), + approval_policy: AskForApproval::from( + self.config.permissions.approval_policy.value(), + ), approvals_reviewer: self.config.approvals_reviewer, permission_profile: permission_profile.clone(), cwd: thread.cwd.clone(), @@ -101,15 +104,16 @@ mod tests { use crate::app::thread_events::ThreadEventChannel; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; + use codex_app_server_protocol::AskForApproval; + use codex_app_server_protocol::FileSystemAccessMode; + use codex_app_server_protocol::FileSystemPath; + use codex_app_server_protocol::FileSystemSandboxEntry; + use codex_app_server_protocol::FileSystemSpecialPath; + use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; + use codex_app_server_protocol::PermissionProfileFileSystemPermissions; + use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_config::types::ApprovalsReviewer; use codex_protocol::models::PermissionProfile; - use codex_protocol::protocol::AskForApproval; - use codex_protocol::protocol::FileSystemAccessMode; - use codex_protocol::protocol::FileSystemPath; - use codex_protocol::protocol::FileSystemSandboxEntry; - use codex_protocol::protocol::FileSystemSandboxPolicy; - use codex_protocol::protocol::FileSystemSpecialPath; - use codex_protocol::protocol::NetworkSandboxPolicy; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -171,7 +175,7 @@ mod tests { app.side_threads .insert(side_thread_id, SideThreadState::new(main_thread_id)); app.config.permissions.approval_policy = - codex_config::Constrained::allow_any(AskForApproval::OnRequest); + codex_config::Constrained::allow_any(AskForApproval::OnRequest.to_core()); app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; let expected_permission_profile = PermissionProfile::workspace_write(); app.chat_widget.handle_thread_session(main_session.clone()); @@ -225,23 +229,27 @@ mod tests { let mut app = make_test_app().await; let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000403").expect("valid thread"); - let profile = PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, + let profile: PermissionProfile = AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/.env".to_string(), + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/.env".to_string(), + }, + access: FileSystemAccessMode::None, }, - access: FileSystemAccessMode::None, - }, - ]), - NetworkSandboxPolicy::Restricted, - ); + ], + glob_scan_max_depth: None, + }, + } + .into(); let session = ThreadSessionState { permission_profile: profile.clone(), ..test_thread_session(thread_id, test_path_buf("/tmp/main")) @@ -256,7 +264,7 @@ mod tests { ); app.chat_widget.handle_thread_session(session.clone()); app.config.permissions.approval_policy = - codex_config::Constrained::allow_any(AskForApproval::OnRequest); + codex_config::Constrained::allow_any(AskForApproval::OnRequest.to_core()); app.sync_active_thread_permission_settings_to_cached_session() .await; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b9770b5c5e..a12918c848 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -13,7 +13,7 @@ //! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message when //! there is a rewind target. //! - `Enter` requests a rollback from core and records a `pending_rollback` guard. -//! - On `EventMsg::ThreadRolledBack`, we either finish an in-flight backtrack request or queue a +//! - On rollback completion, we either finish an in-flight backtrack request or queue a //! rollback trim so it runs in event order with transcript inserts. //! //! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 2d2f9a41c1..5855913055 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -1,7 +1,14 @@ use std::path::PathBuf; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::ReviewTarget; +use codex_app_server_protocol::ThreadRealtimeAudioChunk; +use codex_app_server_protocol::ThreadRealtimeStartTransport; use codex_config::types::ApprovalsReviewer; use codex_protocol::approvals::ElicitationAction; +use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -10,130 +17,132 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::mcp::RequestId as McpRequestId; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::ConversationAudioParams; -use codex_protocol::protocol::ConversationStartParams; -use codex_protocol::protocol::ConversationTextParams; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::ReviewRequest; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; use serde::Serialize; use serde_json::Value; -use crate::permission_compat::legacy_compatible_permission_profile; - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct AppCommand(Op); - #[allow(clippy::large_enum_variant)] -#[allow(dead_code)] -pub(crate) enum AppCommandView<'a> { +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) enum AppCommand { Interrupt, CleanBackgroundTerminals, - RealtimeConversationStart(&'a ConversationStartParams), - RealtimeConversationAudio(&'a ConversationAudioParams), - RealtimeConversationText(&'a ConversationTextParams), + RealtimeConversationStart { + transport: Option, + voice: Option, + }, + RealtimeConversationAudio(ThreadRealtimeAudioChunk), RealtimeConversationClose, RunUserShellCommand { - command: &'a str, + command: String, }, UserTurn { - items: &'a [UserInput], - cwd: &'a PathBuf, + items: Vec, + cwd: PathBuf, approval_policy: AskForApproval, - approvals_reviewer: &'a Option, - permission_profile: &'a PermissionProfile, - model: &'a str, + approvals_reviewer: Option, + permission_profile: PermissionProfile, + model: String, effort: Option, - summary: &'a Option, - service_tier: &'a Option>, - final_output_json_schema: &'a Option, - collaboration_mode: &'a Option, - personality: &'a Option, + summary: Option, + service_tier: Option>, + final_output_json_schema: Option, + collaboration_mode: Option, + personality: Option, }, OverrideTurnContext { - cwd: &'a Option, - approval_policy: &'a Option, - approvals_reviewer: &'a Option, - permission_profile: &'a Option, - windows_sandbox_level: &'a Option, - model: &'a Option, - effort: &'a Option>, - summary: &'a Option, - service_tier: &'a Option>, - collaboration_mode: &'a Option, - personality: &'a Option, + cwd: Option, + approval_policy: Option, + approvals_reviewer: Option, + permission_profile: Option, + windows_sandbox_level: Option, + model: Option, + effort: Option>, + summary: Option, + service_tier: Option>, + collaboration_mode: Option, + personality: Option, }, ExecApproval { - id: &'a str, - turn_id: &'a Option, - decision: &'a ReviewDecision, + id: String, + turn_id: Option, + decision: CommandExecutionApprovalDecision, }, PatchApproval { - id: &'a str, - decision: &'a ReviewDecision, + id: String, + decision: FileChangeApprovalDecision, }, ResolveElicitation { - server_name: &'a str, - request_id: &'a McpRequestId, - decision: &'a ElicitationAction, - content: &'a Option, - meta: &'a Option, + server_name: String, + request_id: McpRequestId, + decision: ElicitationAction, + content: Option, + meta: Option, }, UserInputAnswer { - id: &'a str, - response: &'a RequestUserInputResponse, + id: String, + response: RequestUserInputResponse, }, RequestPermissionsResponse { - id: &'a str, - response: &'a RequestPermissionsResponse, + id: String, + response: RequestPermissionsResponse, }, ReloadUserConfig, ListSkills { - cwds: &'a [PathBuf], + cwds: Vec, force_reload: bool, }, Compact, SetThreadName { - name: &'a str, + name: String, }, Shutdown, ThreadRollback { num_turns: u32, }, Review { - review_request: &'a ReviewRequest, + target: ReviewTarget, + }, + AddToHistory { + text: String, + }, + GetHistoryEntryRequest { + offset: usize, + log_id: u64, + }, + ApproveGuardianDeniedAction { + event: GuardianAssessmentEvent, }, - Other(&'a Op), } impl AppCommand { pub(crate) fn interrupt() -> Self { - Self(Op::Interrupt) + Self::Interrupt } pub(crate) fn clean_background_terminals() -> Self { - Self(Op::CleanBackgroundTerminals) + Self::CleanBackgroundTerminals } - pub(crate) fn realtime_conversation_start(params: ConversationStartParams) -> Self { - Self(Op::RealtimeConversationStart(params)) + pub(crate) fn realtime_conversation_start( + transport: Option, + voice: Option, + ) -> Self { + Self::RealtimeConversationStart { transport, voice } } #[cfg_attr(target_os = "linux", allow(dead_code))] - pub(crate) fn realtime_conversation_audio(params: ConversationAudioParams) -> Self { - Self(Op::RealtimeConversationAudio(params)) + pub(crate) fn realtime_conversation_audio(frame: ThreadRealtimeAudioChunk) -> Self { + Self::RealtimeConversationAudio(frame) } pub(crate) fn realtime_conversation_close() -> Self { - Self(Op::RealtimeConversationClose) + Self::RealtimeConversationClose } pub(crate) fn run_user_shell_command(command: String) -> Self { - Self(Op::RunUserShellCommand { command }) + Self::RunUserShellCommand { command } } #[allow(clippy::too_many_arguments)] @@ -150,21 +159,12 @@ impl AppCommand { collaboration_mode: Option, personality: Option, ) -> Self { - let legacy_profile = - legacy_compatible_permission_profile(&permission_profile, cwd.as_path()); - let sandbox_policy = legacy_profile - .to_legacy_sandbox_policy(cwd.as_path()) - .unwrap_or_else(|err| { - unreachable!("legacy-compatible permissions must project to legacy policy: {err}") - }); - Self(Op::UserTurn { + Self::UserTurn { items, - environments: None, cwd, approval_policy, approvals_reviewer: None, - sandbox_policy, - permission_profile: Some(permission_profile), + permission_profile, model, effort, summary, @@ -172,7 +172,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, - }) + } } #[allow(clippy::too_many_arguments)] @@ -189,11 +189,10 @@ impl AppCommand { collaboration_mode: Option, personality: Option, ) -> Self { - Self(Op::OverrideTurnContext { + Self::OverrideTurnContext { cwd, approval_policy, approvals_reviewer, - sandbox_policy: None, permission_profile, windows_sandbox_level, model, @@ -202,23 +201,23 @@ impl AppCommand { service_tier, collaboration_mode, personality, - }) + } } pub(crate) fn exec_approval( id: String, turn_id: Option, - decision: ReviewDecision, + decision: CommandExecutionApprovalDecision, ) -> Self { - Self(Op::ExecApproval { + Self::ExecApproval { id, turn_id, decision, - }) + } } - pub(crate) fn patch_approval(id: String, decision: ReviewDecision) -> Self { - Self(Op::PatchApproval { id, decision }) + pub(crate) fn patch_approval(id: String, decision: FileChangeApprovalDecision) -> Self { + Self::PatchApproval { id, decision } } pub(crate) fn resolve_elicitation( @@ -228,186 +227,69 @@ impl AppCommand { content: Option, meta: Option, ) -> Self { - Self(Op::ResolveElicitation { + Self::ResolveElicitation { server_name, request_id, decision, content, meta, - }) + } } pub(crate) fn user_input_answer(id: String, response: RequestUserInputResponse) -> Self { - Self(Op::UserInputAnswer { id, response }) + Self::UserInputAnswer { id, response } } pub(crate) fn request_permissions_response( id: String, response: RequestPermissionsResponse, ) -> Self { - Self(Op::RequestPermissionsResponse { id, response }) + Self::RequestPermissionsResponse { id, response } } pub(crate) fn reload_user_config() -> Self { - Self(Op::ReloadUserConfig) + Self::ReloadUserConfig } pub(crate) fn list_skills(cwds: Vec, force_reload: bool) -> Self { - Self(Op::ListSkills { cwds, force_reload }) + Self::ListSkills { cwds, force_reload } } pub(crate) fn compact() -> Self { - Self(Op::Compact) + Self::Compact } pub(crate) fn set_thread_name(name: String) -> Self { - Self(Op::SetThreadName { name }) + Self::SetThreadName { name } + } + + #[allow(dead_code)] + pub(crate) fn shutdown() -> Self { + Self::Shutdown } pub(crate) fn thread_rollback(num_turns: u32) -> Self { - Self(Op::ThreadRollback { num_turns }) + Self::ThreadRollback { num_turns } } - pub(crate) fn review(review_request: ReviewRequest) -> Self { - Self(Op::Review { review_request }) + pub(crate) fn review(target: ReviewTarget) -> Self { + Self::Review { target } } - pub(crate) fn into_core(self) -> Op { - self.0 + pub(crate) fn add_to_history(text: String) -> Self { + Self::AddToHistory { text } + } + + pub(crate) fn history_lookup(offset: usize, log_id: u64) -> Self { + Self::GetHistoryEntryRequest { offset, log_id } + } + + pub(crate) fn approve_guardian_denied_action(event: GuardianAssessmentEvent) -> Self { + Self::ApproveGuardianDeniedAction { event } } pub(crate) fn is_review(&self) -> bool { - matches!(self.view(), AppCommandView::Review { .. }) - } - - pub(crate) fn view(&self) -> AppCommandView<'_> { - match &self.0 { - Op::Interrupt => AppCommandView::Interrupt, - Op::CleanBackgroundTerminals => AppCommandView::CleanBackgroundTerminals, - Op::RealtimeConversationStart(params) => { - AppCommandView::RealtimeConversationStart(params) - } - Op::RealtimeConversationAudio(params) => { - AppCommandView::RealtimeConversationAudio(params) - } - Op::RealtimeConversationText(params) => { - AppCommandView::RealtimeConversationText(params) - } - Op::RealtimeConversationClose => AppCommandView::RealtimeConversationClose, - Op::RunUserShellCommand { command } => AppCommandView::RunUserShellCommand { command }, - Op::UserTurn { - items, - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy: _, - permission_profile, - model, - effort, - summary, - service_tier, - final_output_json_schema, - collaboration_mode, - personality, - environments: _, - } => AppCommandView::UserTurn { - items, - cwd, - approval_policy: *approval_policy, - approvals_reviewer, - permission_profile: match permission_profile.as_ref() { - Some(permission_profile) => permission_profile, - None => unreachable!("AppCommand::user_turn always sets permission_profile"), - }, - model, - effort: *effort, - summary, - service_tier, - final_output_json_schema, - collaboration_mode, - personality, - }, - Op::OverrideTurnContext { - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy: _, - permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } => AppCommandView::OverrideTurnContext { - cwd, - approval_policy, - approvals_reviewer, - permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - }, - Op::ExecApproval { - id, - turn_id, - decision, - } => AppCommandView::ExecApproval { - id, - turn_id, - decision, - }, - Op::PatchApproval { id, decision } => AppCommandView::PatchApproval { id, decision }, - Op::ResolveElicitation { - server_name, - request_id, - decision, - content, - meta, - } => AppCommandView::ResolveElicitation { - server_name, - request_id, - decision, - content, - meta, - }, - Op::UserInputAnswer { id, response } => { - AppCommandView::UserInputAnswer { id, response } - } - Op::RequestPermissionsResponse { id, response } => { - AppCommandView::RequestPermissionsResponse { id, response } - } - Op::ReloadUserConfig => AppCommandView::ReloadUserConfig, - Op::ListSkills { cwds, force_reload } => AppCommandView::ListSkills { - cwds, - force_reload: *force_reload, - }, - Op::Compact => AppCommandView::Compact, - Op::SetThreadName { name } => AppCommandView::SetThreadName { name }, - Op::Shutdown => AppCommandView::Shutdown, - Op::ThreadRollback { num_turns } => AppCommandView::ThreadRollback { - num_turns: *num_turns, - }, - Op::Review { review_request } => AppCommandView::Review { review_request }, - op => AppCommandView::Other(op), - } - } -} - -impl From for AppCommand { - fn from(value: Op) -> Self { - Self(value) - } -} - -impl From<&Op> for AppCommand { - fn from(value: &Op) -> Self { - Self(value.clone()) + matches!(self, Self::Review { .. }) } } @@ -416,9 +298,3 @@ impl From<&AppCommand> for AppCommand { value.clone() } } - -impl From for Op { - fn from(value: AppCommand) -> Self { - value.0 - } -} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index ee0de5082f..519ac1ee08 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -21,21 +21,22 @@ use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_file_search::FileMatch; use codex_protocol::ThreadId; +use codex_protocol::message_history::HistoryEntry; use codex_protocol::openai_models::ModelPreset; -use codex_protocol::protocol::GetHistoryEntryResponseEvent; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::RateLimitSnapshot; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; +use crate::app_command::AppCommand; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; use crate::chatwidget::UserMessage; +use codex_app_server_protocol::AskForApproval; use codex_config::types::ApprovalsReviewer; use codex_features::Feature; use codex_plugin::PluginCapabilitySummary; @@ -44,7 +45,6 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::AskForApproval; use codex_realtime_webrtc::RealtimeWebrtcEvent; use codex_realtime_webrtc::RealtimeWebrtcSessionHandle; @@ -62,6 +62,13 @@ pub(crate) enum ThreadGoalSetMode { ReplaceExisting, } +#[derive(Debug, Clone)] +pub(crate) struct HistoryLookupResponse { + pub(crate) offset: usize, + pub(crate) log_id: u64, + pub(crate) entry: Option, +} + impl RealtimeAudioDeviceKind { pub(crate) fn title(self) -> &'static str { match self { @@ -132,13 +139,13 @@ pub(crate) enum AppEvent { /// Submit an op to the specified thread, regardless of current focus. SubmitThreadOp { thread_id: ThreadId, - op: Op, + op: AppCommand, }, /// Deliver a synthetic history lookup response to a specific thread channel. ThreadHistoryEntryResponse { thread_id: ThreadId, - event: GetHistoryEntryResponseEvent, + event: HistoryLookupResponse, }, /// Start a new session. @@ -180,9 +187,9 @@ pub(crate) enum AppEvent { #[allow(dead_code)] FatalExitRequest(String), - /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids + /// Forward a command to the Agent. Using an `AppEvent` for this avoids /// bubbling channels through layers of widgets. - CodexOp(Op), + CodexOp(AppCommand), /// Approve one retry of a recent auto-review denial selected in the TUI. ApproveRecentAutoReviewDenial { diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index af95fd7dec..bdc5ac3621 100644 --- a/codex-rs/tui/src/app_event_sender.rs +++ b/codex-rs/tui/src/app_event_sender.rs @@ -1,12 +1,13 @@ use std::path::PathBuf; use crate::app_command::AppCommand; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::ReviewTarget; +use codex_app_server_protocol::ThreadRealtimeAudioChunk; use codex_protocol::ThreadId; use codex_protocol::approvals::ElicitationAction; use codex_protocol::mcp::RequestId as McpRequestId; -use codex_protocol::protocol::ConversationAudioParams; -use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::ReviewRequest; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use tokio::sync::mpsc::UnboundedSender; @@ -38,48 +39,50 @@ impl AppEventSender { } pub(crate) fn interrupt(&self) { - self.send(AppEvent::CodexOp(AppCommand::interrupt().into_core())); + self.send(AppEvent::CodexOp(AppCommand::interrupt())); } pub(crate) fn compact(&self) { - self.send(AppEvent::CodexOp(AppCommand::compact().into_core())); + self.send(AppEvent::CodexOp(AppCommand::compact())); } pub(crate) fn set_thread_name(&self, name: String) { - self.send(AppEvent::CodexOp( - AppCommand::set_thread_name(name).into_core(), - )); + self.send(AppEvent::CodexOp(AppCommand::set_thread_name(name))); } - pub(crate) fn review(&self, review_request: ReviewRequest) { - self.send(AppEvent::CodexOp( - AppCommand::review(review_request).into_core(), - )); + pub(crate) fn review(&self, target: ReviewTarget) { + self.send(AppEvent::CodexOp(AppCommand::review(target))); } pub(crate) fn list_skills(&self, cwds: Vec, force_reload: bool) { - self.send(AppEvent::CodexOp( - AppCommand::list_skills(cwds, force_reload).into_core(), - )); + self.send(AppEvent::CodexOp(AppCommand::list_skills( + cwds, + force_reload, + ))); } #[cfg_attr(target_os = "linux", allow(dead_code))] - pub(crate) fn realtime_conversation_audio(&self, params: ConversationAudioParams) { - self.send(AppEvent::CodexOp( - AppCommand::realtime_conversation_audio(params).into_core(), - )); + pub(crate) fn realtime_conversation_audio(&self, frame: ThreadRealtimeAudioChunk) { + self.send(AppEvent::CodexOp(AppCommand::realtime_conversation_audio( + frame, + ))); } pub(crate) fn user_input_answer(&self, id: String, response: RequestUserInputResponse) { - self.send(AppEvent::CodexOp( - AppCommand::user_input_answer(id, response).into_core(), - )); + self.send(AppEvent::CodexOp(AppCommand::user_input_answer( + id, response, + ))); } - pub(crate) fn exec_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) { + pub(crate) fn exec_approval( + &self, + thread_id: ThreadId, + id: String, + decision: CommandExecutionApprovalDecision, + ) { self.send(AppEvent::SubmitThreadOp { thread_id, - op: AppCommand::exec_approval(id, /*turn_id*/ None, decision).into_core(), + op: AppCommand::exec_approval(id, /*turn_id*/ None, decision), }); } @@ -91,14 +94,19 @@ impl AppEventSender { ) { self.send(AppEvent::SubmitThreadOp { thread_id, - op: AppCommand::request_permissions_response(id, response).into_core(), + op: AppCommand::request_permissions_response(id, response), }); } - pub(crate) fn patch_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) { + pub(crate) fn patch_approval( + &self, + thread_id: ThreadId, + id: String, + decision: FileChangeApprovalDecision, + ) { self.send(AppEvent::SubmitThreadOp { thread_id, - op: AppCommand::patch_approval(id, decision).into_core(), + op: AppCommand::patch_approval(id, decision), }); } @@ -113,8 +121,7 @@ impl AppEventSender { ) { self.send(AppEvent::SubmitThreadOp { thread_id, - op: AppCommand::resolve_elicitation(server_name, request_id, decision, content, meta) - .into_core(), + op: AppCommand::resolve_elicitation(server_name, request_id, decision, content, meta), }); } } diff --git a/codex-rs/tui/src/app_server_approval_conversions.rs b/codex-rs/tui/src/app_server_approval_conversions.rs index 894bd36ed7..514148fe78 100644 --- a/codex-rs/tui/src/app_server_approval_conversions.rs +++ b/codex-rs/tui/src/app_server_approval_conversions.rs @@ -1,11 +1,11 @@ +use crate::diff_model::FileChange; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::FileUpdateChange; use codex_app_server_protocol::GrantedPermissionProfile; +use codex_app_server_protocol::NetworkApprovalContext; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; +use codex_app_server_protocol::NetworkApprovalProtocol; use codex_app_server_protocol::PatchChangeKind; -use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::NetworkApprovalContext; -use codex_protocol::protocol::NetworkApprovalProtocol; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use std::collections::HashMap; use std::path::PathBuf; @@ -72,17 +72,11 @@ mod tests { use super::file_update_changes_to_core; use super::granted_permission_profile_from_request; use super::network_approval_context_to_core; + use crate::diff_model::FileChange; use codex_app_server_protocol::FileUpdateChange; + use codex_app_server_protocol::NetworkApprovalContext; + use codex_app_server_protocol::NetworkApprovalProtocol; use codex_app_server_protocol::PatchChangeKind; - use codex_protocol::models::FileSystemPermissions; - use codex_protocol::models::NetworkPermissions; - use codex_protocol::permissions::FileSystemAccessMode; - use codex_protocol::permissions::FileSystemPath; - use codex_protocol::permissions::FileSystemSandboxEntry; - use codex_protocol::permissions::FileSystemSpecialPath; - use codex_protocol::protocol::FileChange; - use codex_protocol::protocol::NetworkApprovalContext; - use codex_protocol::protocol::NetworkApprovalProtocol; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -127,15 +121,19 @@ mod tests { #[test] fn converts_request_permissions_into_granted_permissions() { assert_eq!( - granted_permission_profile_from_request(CoreRequestPermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions::from_read_write_roots( - Some(vec![absolute_path("/tmp/read-only")]), - Some(vec![absolute_path("/tmp/write")]), - )), - }), + granted_permission_profile_from_request(CoreRequestPermissionProfile::from( + codex_app_server_protocol::RequestPermissionProfile { + network: Some(codex_app_server_protocol::AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions { + read: Some(vec![absolute_path("/tmp/read-only")]), + write: Some(vec![absolute_path("/tmp/write")]), + glob_scan_max_depth: None, + entries: None, + }), + } + )), codex_app_server_protocol::GrantedPermissionProfile { network: Some(codex_app_server_protocol::AdditionalNetworkPermissions { enabled: Some(true), @@ -166,18 +164,22 @@ mod tests { #[test] fn converts_request_permissions_into_canonical_granted_permissions() { assert_eq!( - granted_permission_profile_from_request(CoreRequestPermissionProfile { - file_system: Some(FileSystemPermissions { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Write, - }], - glob_scan_max_depth: None, - }), - ..Default::default() - }), + granted_permission_profile_from_request(CoreRequestPermissionProfile::from( + codex_app_server_protocol::RequestPermissionProfile { + network: None, + file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions { + read: None, + write: None, + glob_scan_max_depth: None, + entries: Some(vec![codex_app_server_protocol::FileSystemSandboxEntry { + path: codex_app_server_protocol::FileSystemPath::Special { + value: codex_app_server_protocol::FileSystemSpecialPath::Root, + }, + access: codex_app_server_protocol::FileSystemAccessMode::Write, + }]), + }), + } + )), codex_app_server_protocol::GrantedPermissionProfile { network: None, file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 96757998ff..d2f08b521d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -4,6 +4,7 @@ use crate::legacy_core::append_message_history_entry; use crate::legacy_core::config::Config; use crate::legacy_core::message_history_metadata; use crate::permission_compat::legacy_compatible_permission_profile; +use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; use crate::status::plan_type_display_name; use codex_app_server_client::AppServerClient; @@ -11,6 +12,7 @@ use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::Account; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -29,10 +31,12 @@ use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewDelivery; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; +use codex_app_server_protocol::ReviewTarget; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; @@ -64,8 +68,7 @@ use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse; -use codex_app_server_protocol::ThreadRealtimeAppendTextParams; -use codex_app_server_protocol::ThreadRealtimeAppendTextResponse; +use codex_app_server_protocol::ThreadRealtimeAudioChunk; use codex_app_server_protocol::ThreadRealtimeStartParams; use codex_app_server_protocol::ThreadRealtimeStartResponse; use codex_app_server_protocol::ThreadRealtimeStartTransport; @@ -93,24 +96,13 @@ use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; +use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffortPreset; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::ConversationAudioParams; -use codex_protocol::protocol::ConversationStartParams; -use codex_protocol::protocol::ConversationStartTransport; -use codex_protocol::protocol::ConversationTextParams; -use codex_protocol::protocol::CreditsSnapshot; -use codex_protocol::protocol::GuardianAssessmentEvent; -use codex_protocol::protocol::RateLimitSnapshot; -use codex_protocol::protocol::RateLimitWindow; -use codex_protocol::protocol::ReviewRequest; -use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; -use codex_protocol::protocol::SessionNetworkProxyRuntime; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; @@ -145,30 +137,6 @@ pub(crate) struct AppServerSession { remote_cwd_override: Option, } -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct ThreadSessionState { - pub(crate) thread_id: ThreadId, - pub(crate) forked_from_id: Option, - pub(crate) fork_parent_title: Option, - pub(crate) thread_name: Option, - pub(crate) model: String, - pub(crate) model_provider_id: String, - pub(crate) service_tier: Option, - pub(crate) approval_policy: AskForApproval, - pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - /// Canonical active permissions for this session. Legacy app-server - /// responses are converted to a profile at ingestion time using the - /// response cwd so cached sessions do not reinterpret cwd-bound grants. - pub(crate) permission_profile: PermissionProfile, - pub(crate) cwd: AbsolutePathBuf, - pub(crate) instruction_source_paths: Vec, - pub(crate) reasoning_effort: Option, - pub(crate) history_log_id: u64, - pub(crate) history_entry_count: u64, - pub(crate) network_proxy: Option, - pub(crate) rollout_path: Option, -} - #[derive(Clone, Copy)] enum ThreadParamsMode { Embedded, @@ -571,7 +539,7 @@ impl AppServerSession { responsesapi_client_metadata: None, environments: None, cwd: Some(cwd), - approval_policy: Some(approval_policy.into()), + approval_policy: Some(approval_policy), approvals_reviewer: Some(approvals_reviewer.into()), sandbox_policy, permission_profile, @@ -862,7 +830,7 @@ impl AppServerSession { pub(crate) async fn review_start( &mut self, thread_id: ThreadId, - review_request: ReviewRequest, + target: ReviewTarget, ) -> Result { let request_id = self.next_request_id(); self.client @@ -870,7 +838,7 @@ impl AppServerSession { request_id, params: ReviewStartParams { thread_id: thread_id.to_string(), - target: review_target_to_app_server(review_request.target), + target, delivery: Some(ReviewDelivery::Inline), }, }) @@ -910,29 +878,14 @@ impl AppServerSession { pub(crate) async fn thread_realtime_start( &mut self, thread_id: ThreadId, - params: ConversationStartParams, + transport: Option, + voice: Option, ) -> Result<()> { let request_id = self.next_request_id(); + let params = thread_realtime_start_params(thread_id, transport, voice)?; let _: ThreadRealtimeStartResponse = self .client - .request_typed(ClientRequest::ThreadRealtimeStart { - request_id, - params: ThreadRealtimeStartParams { - thread_id: thread_id.to_string(), - output_modality: params.output_modality, - prompt: params.prompt, - session_id: params.session_id, - voice: params.voice, - transport: params.transport.map(|transport| match transport { - ConversationStartTransport::Websocket => { - ThreadRealtimeStartTransport::Websocket - } - ConversationStartTransport::Webrtc { sdp } => { - ThreadRealtimeStartTransport::Webrtc { sdp } - } - }), - }, - }) + .request_typed(ClientRequest::ThreadRealtimeStart { request_id, params }) .await .wrap_err("thread/realtime/start failed in TUI")?; Ok(()) @@ -941,7 +894,7 @@ impl AppServerSession { pub(crate) async fn thread_realtime_audio( &mut self, thread_id: ThreadId, - params: ConversationAudioParams, + frame: ThreadRealtimeAudioChunk, ) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadRealtimeAppendAudioResponse = self @@ -950,7 +903,7 @@ impl AppServerSession { request_id, params: ThreadRealtimeAppendAudioParams { thread_id: thread_id.to_string(), - audio: params.frame.into(), + audio: frame, }, }) .await @@ -958,26 +911,6 @@ impl AppServerSession { Ok(()) } - pub(crate) async fn thread_realtime_text( - &mut self, - thread_id: ThreadId, - params: ConversationTextParams, - ) -> Result<()> { - let request_id = self.next_request_id(); - let _: ThreadRealtimeAppendTextResponse = self - .client - .request_typed(ClientRequest::ThreadRealtimeAppendText { - request_id, - params: ThreadRealtimeAppendTextParams { - thread_id: thread_id.to_string(), - text: params.text, - }, - }) - .await - .wrap_err("thread/realtime/appendText failed in TUI")?; - Ok(()) - } - pub(crate) async fn thread_realtime_stop(&mut self, thread_id: ThreadId) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadRealtimeStopResponse = self @@ -1024,6 +957,34 @@ impl AppServerSession { } } +fn thread_realtime_start_params( + thread_id: ThreadId, + transport: Option, + voice: Option, +) -> Result { + let mut value = serde_json::Map::new(); + value.insert( + "threadId".to_string(), + serde_json::Value::String(thread_id.to_string()), + ); + value.insert( + "outputModality".to_string(), + serde_json::Value::String("audio".to_string()), + ); + if let Some(transport) = transport { + value.insert( + "transport".to_string(), + serde_json::to_value(transport).wrap_err("serializing realtime transport")?, + ); + } + if let Some(voice) = voice { + value.insert("voice".to_string(), voice); + } + + serde_json::from_value(serde_json::Value::Object(value)) + .wrap_err("mapping TUI realtime start params to app-server params") +} + pub(crate) fn status_account_display_from_auth_mode( auth_mode: Option, plan_type: Option, @@ -1299,7 +1260,7 @@ async fn thread_session_state_from_thread_start_response( response.model.clone(), response.model_provider.clone(), response.service_tier, - response.approval_policy.to_core(), + response.approval_policy, response.approvals_reviewer.to_core(), response .permission_profile @@ -1331,7 +1292,7 @@ async fn thread_session_state_from_thread_resume_response( response.model.clone(), response.model_provider.clone(), response.service_tier, - response.approval_policy.to_core(), + response.approval_policy, response.approvals_reviewer.to_core(), response .permission_profile @@ -1363,7 +1324,7 @@ async fn thread_session_state_from_thread_fork_response( response.model.clone(), response.model_provider.clone(), response.service_tier, - response.approval_policy.to_core(), + response.approval_policy, response.approvals_reviewer.to_core(), response .permission_profile @@ -1383,25 +1344,6 @@ async fn thread_session_state_from_thread_fork_response( .await } -fn review_target_to_app_server( - target: CoreReviewTarget, -) -> codex_app_server_protocol::ReviewTarget { - match target { - CoreReviewTarget::UncommittedChanges => { - codex_app_server_protocol::ReviewTarget::UncommittedChanges - } - CoreReviewTarget::BaseBranch { branch } => { - codex_app_server_protocol::ReviewTarget::BaseBranch { branch } - } - CoreReviewTarget::Commit { sha, title } => { - codex_app_server_protocol::ReviewTarget::Commit { sha, title } - } - CoreReviewTarget::Custom { instructions } => { - codex_app_server_protocol::ReviewTarget::Custom { instructions } - } - } -} - #[expect( clippy::too_many_arguments, reason = "session mapping keeps explicit fields" @@ -1452,68 +1394,31 @@ async fn thread_session_state_from_thread_response( }) } -pub(crate) fn app_server_rate_limit_snapshots_to_core( +pub(crate) fn app_server_rate_limit_snapshots( response: GetAccountRateLimitsResponse, ) -> Vec { let mut snapshots = Vec::new(); - snapshots.push(app_server_rate_limit_snapshot_to_core(response.rate_limits)); + snapshots.push(response.rate_limits); if let Some(by_limit_id) = response.rate_limits_by_limit_id { - snapshots.extend( - by_limit_id - .into_values() - .map(app_server_rate_limit_snapshot_to_core), - ); + snapshots.extend(by_limit_id.into_values()); } snapshots } -pub(crate) fn app_server_rate_limit_snapshot_to_core( - snapshot: codex_app_server_protocol::RateLimitSnapshot, -) -> RateLimitSnapshot { - RateLimitSnapshot { - limit_id: snapshot.limit_id, - limit_name: snapshot.limit_name, - primary: snapshot.primary.map(app_server_rate_limit_window_to_core), - secondary: snapshot.secondary.map(app_server_rate_limit_window_to_core), - credits: snapshot.credits.map(app_server_credits_snapshot_to_core), - plan_type: snapshot.plan_type, - rate_limit_reached_type: snapshot.rate_limit_reached_type.map(Into::into), - } -} - -fn app_server_rate_limit_window_to_core( - window: codex_app_server_protocol::RateLimitWindow, -) -> RateLimitWindow { - RateLimitWindow { - used_percent: window.used_percent as f64, - window_minutes: window.window_duration_mins, - resets_at: window.resets_at, - } -} - -fn app_server_credits_snapshot_to_core( - snapshot: codex_app_server_protocol::CreditsSnapshot, -) -> CreditsSnapshot { - CreditsSnapshot { - has_credits: snapshot.has_credits, - unlimited: snapshot.unlimited, - balance: snapshot.balance, - } -} - #[cfg(test)] mod tests { use super::*; use crate::legacy_core::config::ConfigBuilder; + use codex_app_server_protocol::FileSystemAccessMode; + use codex_app_server_protocol::FileSystemPath; + use codex_app_server_protocol::FileSystemSandboxEntry; + use codex_app_server_protocol::FileSystemSpecialPath; + use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; + use codex_app_server_protocol::PermissionProfileFileSystemPermissions; + use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; - use codex_protocol::models::ManagedFileSystemPermissions; - use codex_protocol::permissions::FileSystemAccessMode; - use codex_protocol::permissions::FileSystemPath; - use codex_protocol::permissions::FileSystemSandboxEntry; - use codex_protocol::permissions::FileSystemSpecialPath; - use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -1610,8 +1515,9 @@ mod tests { fn sandbox_mode_does_not_project_non_cwd_write_roots_for_remote_sessions() { let cwd = test_path_buf("/workspace/project").abs(); let extra_root = test_path_buf("/workspace/cache").abs(); - let permission_profile = PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Restricted { + let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1626,8 +1532,8 @@ mod tests { ], glob_scan_max_depth: None, }, - network: NetworkSandboxPolicy::Restricted, - }; + } + .into(); assert_eq!( sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()), @@ -1638,8 +1544,9 @@ mod tests { #[test] fn sandbox_mode_projects_cwd_write_for_remote_sessions() { let cwd = test_path_buf("/workspace/project").abs(); - let permission_profile = PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Restricted { + let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1656,8 +1563,8 @@ mod tests { ], glob_scan_max_depth: None, }, - network: NetworkSandboxPolicy::Restricted, - }; + } + .into(); assert_eq!( sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()), @@ -1751,7 +1658,7 @@ mod tests { path: None, cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0".to_string(), - source: codex_protocol::protocol::SessionSource::Cli.into(), + source: codex_app_server_protocol::SessionSource::Cli, agent_nickname: None, agent_role: None, git_info: None, @@ -1785,7 +1692,7 @@ mod tests { service_tier: None, cwd: test_path_buf("/tmp/project").abs(), instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()], - approval_policy: codex_protocol::protocol::AskForApproval::Never.into(), + approval_policy: codex_app_server_protocol::AskForApproval::Never, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, sandbox: read_only_profile .to_legacy_sandbox_policy(test_path_buf("/tmp/project").as_path()) diff --git a/codex-rs/tui/src/approval_display.rs b/codex-rs/tui/src/approval_display.rs new file mode 100644 index 0000000000..ba824cc28e --- /dev/null +++ b/codex-rs/tui/src/approval_display.rs @@ -0,0 +1,45 @@ +//! Display-level approval decisions used by the TUI approval overlay. + +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::approvals::NetworkPolicyAmendment; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewDecision { + Approved, + ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: ExecPolicyAmendment, + }, + ApprovedForSession, + NetworkPolicyAmendment { + network_policy_amendment: NetworkPolicyAmendment, + }, + #[default] + Denied, + TimedOut, + Abort, +} + +impl From for CommandExecutionApprovalDecision { + fn from(value: ReviewDecision) -> Self { + match value { + ReviewDecision::Approved => Self::Accept, + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment, + } => Self::AcceptWithExecpolicyAmendment { + execpolicy_amendment: proposed_execpolicy_amendment.into(), + }, + ReviewDecision::ApprovedForSession => Self::AcceptForSession, + ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment, + } => Self::ApplyNetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.into(), + }, + ReviewDecision::Denied | ReviewDecision::TimedOut => Self::Decline, + ReviewDecision::Abort => Self::Cancel, + } + } +} diff --git a/codex-rs/tui/src/approval_events.rs b/codex-rs/tui/src/approval_events.rs new file mode 100644 index 0000000000..661a9abb25 --- /dev/null +++ b/codex-rs/tui/src/approval_events.rs @@ -0,0 +1,105 @@ +//! TUI-owned approval request models used while rendering and queueing prompts. + +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::approval_display::ReviewDecision; +use crate::diff_model::FileChange; +use codex_app_server_protocol::NetworkApprovalContext; +use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::approvals::NetworkPolicyAmendment; +use codex_protocol::approvals::NetworkPolicyRuleAction; +use codex_protocol::parse_command::ParsedCommand; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ExecApprovalRequestEvent { + pub(crate) call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) approval_id: Option, + #[serde(default)] + pub(crate) turn_id: String, + pub(crate) command: Vec, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) proposed_execpolicy_amendment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) proposed_network_policy_amendments: Option>, + #[serde(default)] + pub(crate) available_decisions: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) network_approval_context: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) additional_permissions: Option, + pub(crate) parsed_cmd: Vec, +} + +impl ExecApprovalRequestEvent { + pub(crate) fn effective_approval_id(&self) -> String { + self.approval_id + .clone() + .unwrap_or_else(|| self.call_id.clone()) + } + + pub(crate) fn effective_available_decisions(&self) -> Vec { + match &self.available_decisions { + Some(decisions) => decisions.clone(), + None => Self::default_available_decisions( + self.network_approval_context.as_ref(), + self.proposed_execpolicy_amendment.as_ref(), + self.proposed_network_policy_amendments.as_deref(), + self.additional_permissions.as_ref(), + ), + } + } + + pub(crate) fn default_available_decisions( + network_approval_context: Option<&NetworkApprovalContext>, + proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>, + proposed_network_policy_amendments: Option<&[NetworkPolicyAmendment]>, + additional_permissions: Option<&codex_protocol::models::AdditionalPermissionProfile>, + ) -> Vec { + if network_approval_context.is_some() { + let mut decisions = vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession]; + if let Some(amendment) = proposed_network_policy_amendments.and_then(|amendments| { + amendments + .iter() + .find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow) + }) { + decisions.push(ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: amendment.clone(), + }); + } + decisions.push(ReviewDecision::Abort); + return decisions; + } + + if additional_permissions.is_some() { + return vec![ReviewDecision::Approved, ReviewDecision::Abort]; + } + + let mut decisions = vec![ReviewDecision::Approved]; + if let Some(prefix) = proposed_execpolicy_amendment { + decisions.push(ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: prefix.clone(), + }); + } + decisions.push(ReviewDecision::Abort); + decisions + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ApplyPatchApprovalRequestEvent { + pub(crate) call_id: String, + #[serde(default)] + pub(crate) turn_id: String, + pub(crate) changes: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) grant_root: Option, +} diff --git a/codex-rs/tui/src/auto_review_denials.rs b/codex-rs/tui/src/auto_review_denials.rs index 16a8e43058..e51e071e21 100644 --- a/codex-rs/tui/src/auto_review_denials.rs +++ b/codex-rs/tui/src/auto_review_denials.rs @@ -1,8 +1,8 @@ use std::collections::VecDeque; -use codex_protocol::protocol::GuardianAssessmentAction; -use codex_protocol::protocol::GuardianAssessmentEvent; -use codex_protocol::protocol::GuardianAssessmentStatus; +use codex_protocol::approvals::GuardianAssessmentAction; +use codex_protocol::approvals::GuardianAssessmentEvent; +use codex_protocol::approvals::GuardianAssessmentStatus; const MAX_RECENT_DENIALS: usize = 10; @@ -76,7 +76,7 @@ pub(crate) fn action_summary(action: &GuardianAssessmentAction) -> String { #[cfg(test)] mod tests { - use codex_protocol::protocol::GuardianCommandSource; + use codex_protocol::approvals::GuardianCommandSource; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index a87ccddb73..30429b7665 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -1,8 +1,8 @@ +#[cfg(test)] +use crate::app_command::AppCommand as Op; use codex_protocol::ThreadId; use codex_protocol::approvals::ElicitationAction; use codex_protocol::mcp::RequestId as McpRequestId; -#[cfg(test)] -use codex_protocol::protocol::Op; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 123bd9e4ec..51f0c738b7 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -15,14 +15,18 @@ use std::collections::HashMap; use std::path::PathBuf; use crate::app::app_server_requests::ResolvedAppServerRequest; +#[cfg(test)] +use crate::app_command::AppCommand as Op; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::approval_display::ReviewDecision; use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::list_selection_view::ListSelectionView; use crate::bottom_pane::list_selection_view::SelectionItem; use crate::bottom_pane::list_selection_view::SelectionViewParams; use crate::bottom_pane::popup_consts::accept_cancel_hint_line; +use crate::diff_model::FileChange; use crate::exec_command::strip_bash_lc_and_escape; use crate::history_cell; use crate::key_hint; @@ -34,21 +38,19 @@ use crate::keymap::primary_binding; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; +use codex_app_server_protocol::AdditionalPermissionProfile; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; +use codex_app_server_protocol::NetworkApprovalContext; +use codex_app_server_protocol::NetworkPolicyRuleAction; use codex_features::Features; use codex_protocol::ThreadId; +use codex_protocol::approvals::ElicitationAction; use codex_protocol::mcp::RequestId; -use codex_protocol::models::AdditionalPermissionProfile; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSpecialPath; -use codex_protocol::protocol::ElicitationAction; -use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::NetworkApprovalContext; -use codex_protocol::protocol::NetworkPolicyRuleAction; -#[cfg(test)] -use codex_protocol::protocol::Op; -use codex_protocol::protocol::ReviewDecision; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -73,7 +75,7 @@ pub(crate) enum ApprovalRequest { id: String, command: Vec, reason: Option, - available_decisions: Vec, + available_decisions: Vec, network_approval_context: Option, additional_permissions: Option, }, @@ -304,7 +306,10 @@ impl ApprovalOverlay { }; if let Some(request) = self.current_request.as_ref() { match (request, &option.decision) { - (ApprovalRequest::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => { + ( + ApprovalRequest::Exec { id, command, .. }, + ApprovalDecision::Command(decision), + ) => { self.handle_exec_decision(id, command, decision.clone()); } ( @@ -315,7 +320,10 @@ impl ApprovalOverlay { }, ApprovalDecision::Permissions(decision), ) => self.handle_permissions_decision(call_id, permissions, *decision), - (ApprovalRequest::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { + ( + ApprovalRequest::ApplyPatch { id, .. }, + ApprovalDecision::FileChange(decision), + ) => { self.handle_patch_decision(id, decision.clone()); } ( @@ -336,14 +344,19 @@ impl ApprovalOverlay { self.advance_queue(); } - fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { + fn handle_exec_decision( + &self, + id: &str, + command: &[String], + decision: CommandExecutionApprovalDecision, + ) { let Some(request) = self.current_request.as_ref() else { return; }; if request.thread_label().is_none() { let cell = history_cell::new_approval_decision_cell( command.to_vec(), - decision.clone(), + command_decision_to_review_decision(&decision), history_cell::ApprovalDecisionActor::User, ); self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); @@ -403,7 +416,7 @@ impl ApprovalOverlay { ); } - fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) { + fn handle_patch_decision(&self, id: &str, decision: FileChangeApprovalDecision) { let Some(thread_id) = self .current_request .as_ref() @@ -455,7 +468,11 @@ impl ApprovalOverlay { { match request { ApprovalRequest::Exec { id, command, .. } => { - self.handle_exec_decision(id, command, ReviewDecision::Abort); + self.handle_exec_decision( + id, + command, + CommandExecutionApprovalDecision::Cancel, + ); } ApprovalRequest::Permissions { call_id, @@ -469,7 +486,7 @@ impl ApprovalOverlay { ); } ApprovalRequest::ApplyPatch { id, .. } => { - self.handle_patch_decision(id, ReviewDecision::Abort); + self.handle_patch_decision(id, FileChangeApprovalDecision::Cancel); } ApprovalRequest::McpElicitation { server_name, @@ -728,7 +745,8 @@ fn build_header(request: &ApprovalRequest) -> Box { #[derive(Clone)] enum ApprovalDecision { - Review(ReviewDecision), + Command(CommandExecutionApprovalDecision), + FileChange(FileChangeApprovalDecision), Permissions(PermissionsDecision), McpElicitation(ElicitationAction), } @@ -748,8 +766,29 @@ struct ApprovalOption { shortcuts: Vec, } +fn command_decision_to_review_decision( + decision: &CommandExecutionApprovalDecision, +) -> ReviewDecision { + match decision { + CommandExecutionApprovalDecision::Accept => ReviewDecision::Approved, + CommandExecutionApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession, + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment, + } => ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: execpolicy_amendment.clone().into_core(), + }, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment, + } => ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.clone().into_core(), + }, + CommandExecutionApprovalDecision::Decline => ReviewDecision::Denied, + CommandExecutionApprovalDecision::Cancel => ReviewDecision::Abort, + } +} + fn exec_options( - available_decisions: &[ReviewDecision], + available_decisions: &[CommandExecutionApprovalDecision], network_approval_context: Option<&NetworkApprovalContext>, additional_permissions: Option<&AdditionalPermissionProfile>, keymap: &ApprovalKeymap, @@ -757,20 +796,19 @@ fn exec_options( available_decisions .iter() .filter_map(|decision| match decision { - ReviewDecision::Approved => Some(ApprovalOption { + CommandExecutionApprovalDecision::Accept => Some(ApprovalOption { label: if network_approval_context.is_some() { "Yes, just this once".to_string() } else { "Yes, proceed".to_string() }, - decision: ApprovalDecision::Review(ReviewDecision::Approved), + decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Accept), shortcuts: keymap.approve.clone(), }), - ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment, + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment, } => { - let rendered_prefix = - strip_bash_lc_and_escape(proposed_execpolicy_amendment.command()); + let rendered_prefix = strip_bash_lc_and_escape(&execpolicy_amendment.command); if rendered_prefix.contains('\n') || rendered_prefix.contains('\r') { return None; } @@ -779,15 +817,15 @@ fn exec_options( label: format!( "Yes, and don't ask again for commands that start with `{rendered_prefix}`" ), - decision: ApprovalDecision::Review( - ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: proposed_execpolicy_amendment.clone(), + decision: ApprovalDecision::Command( + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment: execpolicy_amendment.clone(), }, ), shortcuts: keymap.approve_for_prefix.clone(), }) } - ReviewDecision::ApprovedForSession => Some(ApprovalOption { + CommandExecutionApprovalDecision::AcceptForSession => Some(ApprovalOption { label: if network_approval_context.is_some() { "Yes, and allow this host for this conversation".to_string() } else if additional_permissions.is_some() { @@ -795,10 +833,12 @@ fn exec_options( } else { "Yes, and don't ask again for this command in this session".to_string() }, - decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), + decision: ApprovalDecision::Command( + CommandExecutionApprovalDecision::AcceptForSession, + ), shortcuts: keymap.approve_for_session.clone(), }), - ReviewDecision::NetworkPolicyAmendment { + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { network_policy_amendment, } => { let (label, shortcuts) = match network_policy_amendment.action { @@ -813,21 +853,22 @@ fn exec_options( }; Some(ApprovalOption { label, - decision: ApprovalDecision::Review(ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: network_policy_amendment.clone(), - }), + decision: ApprovalDecision::Command( + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.clone(), + }, + ), shortcuts, }) } - ReviewDecision::Denied => Some(ApprovalOption { + CommandExecutionApprovalDecision::Decline => Some(ApprovalOption { label: "No, continue without running it".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Denied), + decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Decline), shortcuts: keymap.deny.clone(), }), - ReviewDecision::TimedOut => None, - ReviewDecision::Abort => Some(ApprovalOption { + CommandExecutionApprovalDecision::Cancel => Some(ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Abort), + decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Cancel), shortcuts: keymap.decline.clone(), }), }) @@ -851,6 +892,7 @@ pub(crate) fn format_additional_permissions_rule( file_system .entries .iter() + .flatten() .filter(|entry| entry.access == FileSystemAccessMode::Read), ); if !reads.is_empty() { @@ -860,6 +902,7 @@ pub(crate) fn format_additional_permissions_rule( file_system .entries .iter() + .flatten() .filter(|entry| entry.access == FileSystemAccessMode::Write), ); if !writes.is_empty() { @@ -869,6 +912,7 @@ pub(crate) fn format_additional_permissions_rule( file_system .entries .iter() + .flatten() .filter(|entry| entry.access == FileSystemAccessMode::None), ); if !denied_reads.is_empty() { @@ -885,7 +929,14 @@ pub(crate) fn format_additional_permissions_rule( pub(crate) fn format_requested_permissions_rule( permissions: &RequestPermissionProfile, ) -> Option { - format_additional_permissions_rule(&permissions.clone().into()) + let permissions = + crate::app_server_approval_conversions::granted_permission_profile_from_request( + permissions.clone(), + ); + format_additional_permissions_rule(&AdditionalPermissionProfile { + network: permissions.network, + file_system: permissions.file_system, + }) } fn format_file_system_entry_paths<'a>( @@ -923,17 +974,17 @@ fn patch_options(keymap: &ApprovalKeymap) -> Vec { vec![ ApprovalOption { label: "Yes, proceed".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Approved), + decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Accept), shortcuts: keymap.approve.clone(), }, ApprovalOption { label: "Yes, and don't ask again for these files".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), + decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::AcceptForSession), shortcuts: keymap.approve_for_session.clone(), }, ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Abort), + decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Cancel), shortcuts: keymap.decline.clone(), }, ] @@ -1018,14 +1069,13 @@ fn elicitation_options(keymap: &ApprovalKeymap) -> Vec { mod tests { use super::*; use crate::app_event::AppEvent; + use codex_app_server_protocol::AdditionalFileSystemPermissions; + use codex_app_server_protocol::AdditionalNetworkPermissions; + use codex_app_server_protocol::ExecPolicyAmendment; + use codex_app_server_protocol::NetworkApprovalProtocol; + use codex_app_server_protocol::NetworkPolicyAmendment; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; - use codex_protocol::permissions::FileSystemPath; - use codex_protocol::permissions::FileSystemSandboxEntry; - use codex_protocol::permissions::FileSystemSpecialPath; - use codex_protocol::protocol::ExecPolicyAmendment; - use codex_protocol::protocol::NetworkApprovalProtocol; - use codex_protocol::protocol::NetworkPolicyAmendment; use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyModifiers; use insta::assert_snapshot; @@ -1101,7 +1151,10 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), - available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], network_approval_context: None, additional_permissions: None, } @@ -1174,7 +1227,7 @@ mod tests { break; } } - assert_eq!(decision, Some(ReviewDecision::Abort)); + assert_eq!(decision, Some(CommandExecutionApprovalDecision::Cancel)); } #[test] @@ -1237,7 +1290,10 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: None, - available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Denied], + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Decline, + ], network_approval_context: None, additional_permissions: None, }, @@ -1254,7 +1310,7 @@ mod tests { .. } = ev { - assert_eq!(decision, ReviewDecision::Denied); + assert_eq!(decision, CommandExecutionApprovalDecision::Decline); saw_denied = true; break; } @@ -1278,8 +1334,8 @@ mod tests { command: vec!["curl".to_string(), "https://example.com".to_string()], reason: None, available_decisions: vec![ - ReviewDecision::Approved, - ReviewDecision::NetworkPolicyAmendment { + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { network_policy_amendment: amendment.clone(), }, ], @@ -1304,7 +1360,7 @@ mod tests { { assert_eq!( decision, - ReviewDecision::NetworkPolicyAmendment { + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { network_policy_amendment: amendment } ); @@ -1351,7 +1407,10 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: None, - available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], network_approval_context: None, additional_permissions: None, }, @@ -1382,7 +1441,10 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: None, - available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], network_approval_context: None, additional_permissions: None, }, @@ -1417,7 +1479,10 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: None, - available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], network_approval_context: None, additional_permissions: None, }, @@ -1443,13 +1508,13 @@ mod tests { command: vec!["echo".to_string()], reason: None, available_decisions: vec![ - ReviewDecision::Approved, - ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![ - "echo".to_string(), - ]), + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment: ExecPolicyAmendment { + command: vec!["echo".to_string()], + }, }, - ReviewDecision::Abort, + CommandExecutionApprovalDecision::Cancel, ], network_approval_context: None, additional_permissions: None, @@ -1467,10 +1532,10 @@ mod tests { { assert_eq!( decision, - ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![ - "echo".to_string() - ]) + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment: ExecPolicyAmendment { + command: vec!["echo".to_string()], + } } ); saw_op = true; @@ -1495,15 +1560,15 @@ mod tests { command: vec!["curl".to_string(), "https://example.com".to_string()], reason: None, available_decisions: vec![ - ReviewDecision::Approved, - ReviewDecision::ApprovedForSession, - ReviewDecision::NetworkPolicyAmendment { + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptForSession, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { network_policy_amendment: NetworkPolicyAmendment { host: "example.com".to_string(), action: NetworkPolicyRuleAction::Allow, }, }, - ReviewDecision::Abort, + CommandExecutionApprovalDecision::Cancel, ], network_approval_context: Some(NetworkApprovalContext { host: "example.com".to_string(), @@ -1533,7 +1598,10 @@ mod tests { id: "test".into(), command, reason: None, - available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], network_approval_context: None, additional_permissions: None, }; @@ -1569,15 +1637,15 @@ mod tests { let keymap = crate::keymap::RuntimeKeymap::defaults(); let options = exec_options( &[ - ReviewDecision::Approved, - ReviewDecision::ApprovedForSession, - ReviewDecision::NetworkPolicyAmendment { + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptForSession, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { network_policy_amendment: NetworkPolicyAmendment { host: "example.com".to_string(), action: NetworkPolicyRuleAction::Allow, }, }, - ReviewDecision::Abort, + CommandExecutionApprovalDecision::Cancel, ], Some(&network_context), /*additional_permissions*/ None, @@ -1601,9 +1669,9 @@ mod tests { let keymap = crate::keymap::RuntimeKeymap::defaults(); let options = exec_options( &[ - ReviewDecision::Approved, - ReviewDecision::ApprovedForSession, - ReviewDecision::Abort, + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptForSession, + CommandExecutionApprovalDecision::Cancel, ], /*network_approval_context*/ None, /*additional_permissions*/ None, @@ -1625,14 +1693,20 @@ mod tests { fn additional_permissions_exec_options_hide_execpolicy_amendment() { let keymap = crate::keymap::RuntimeKeymap::defaults(); let additional_permissions = AdditionalPermissionProfile { - file_system: Some(FileSystemPermissions::from_read_write_roots( - Some(vec![absolute_path("/tmp/readme.txt")]), - Some(vec![absolute_path("/tmp/out.txt")]), - )), - ..Default::default() + network: None, + file_system: Some( + FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/readme.txt")]), + Some(vec![absolute_path("/tmp/out.txt")]), + ) + .into(), + ), }; let options = exec_options( - &[ReviewDecision::Approved, ReviewDecision::Abort], + &[ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], /*network_approval_context*/ None, Some(&additional_permissions), &keymap.approval, @@ -1669,8 +1743,11 @@ mod tests { #[test] fn additional_permissions_rule_shows_non_path_file_system_entries() { let additional_permissions = AdditionalPermissionProfile { - file_system: Some(FileSystemPermissions { - entries: vec![ + network: None, + file_system: Some(AdditionalFileSystemPermissions { + read: None, + write: None, + entries: Some(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, @@ -1683,10 +1760,9 @@ mod tests { }, access: FileSystemAccessMode::None, }, - ], + ]), glob_scan_max_depth: None, }), - ..Default::default() }; assert_eq!( @@ -1795,16 +1871,22 @@ mod tests { id: "test".into(), command: vec!["cat".into(), "/tmp/readme.txt".into()], reason: None, - available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], network_approval_context: None, additional_permissions: Some(AdditionalPermissionProfile { - network: Some(NetworkPermissions { + network: Some(AdditionalNetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions::from_read_write_roots( - Some(vec![absolute_path("/tmp/readme.txt")]), - Some(vec![absolute_path("/tmp/out.txt")]), - )), + file_system: Some( + FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/readme.txt")]), + Some(vec![absolute_path("/tmp/out.txt")]), + ) + .into(), + ), }), }; @@ -1845,16 +1927,22 @@ mod tests { id: "test".into(), command: vec!["cat".into(), "/tmp/readme.txt".into()], reason: Some("need filesystem access".into()), - available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], + available_decisions: vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ], network_approval_context: None, additional_permissions: Some(AdditionalPermissionProfile { - network: Some(NetworkPermissions { + network: Some(AdditionalNetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions::from_read_write_roots( - Some(vec![absolute_path("/tmp/readme.txt")]), - Some(vec![absolute_path("/tmp/out.txt")]), - )), + file_system: Some( + FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/readme.txt")]), + Some(vec![absolute_path("/tmp/out.txt")]), + ) + .into(), + ), }), }; @@ -1920,15 +2008,15 @@ mod tests { command: vec!["curl".into(), "https://example.com".into()], reason: Some("network request blocked".into()), available_decisions: vec![ - ReviewDecision::Approved, - ReviewDecision::ApprovedForSession, - ReviewDecision::NetworkPolicyAmendment { + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptForSession, + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { network_policy_amendment: NetworkPolicyAmendment { host: "example.com".to_string(), action: NetworkPolicyRuleAction::Allow, }, }, - ReviewDecision::Abort, + CommandExecutionApprovalDecision::Cancel, ], network_approval_context: Some(NetworkApprovalContext { host: "example.com".to_string(), @@ -2130,6 +2218,6 @@ mod tests { break; } } - assert_eq!(decision, Some(ReviewDecision::Approved)); + assert_eq!(decision, Some(CommandExecutionApprovalDecision::Accept)); } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c879743425..3a88e0883f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -5679,7 +5679,7 @@ mod tests { dependencies: None, policy: None, path_to_skills_md: skill_path.clone(), - scope: codex_protocol::protocol::SkillScope::User, + scope: crate::test_support::skill_scope_user(), }])); let ActivePopup::Skill(popup) = &composer.active_popup else { @@ -5721,7 +5721,7 @@ mod tests { dependencies: None, policy: None, path_to_skills_md: skill_path.clone(), - scope: codex_protocol::protocol::SkillScope::Repo, + scope: crate::test_support::skill_scope_repo(), }])); composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { config_name: "google-calendar@debug".to_string(), @@ -5812,7 +5812,7 @@ mod tests { dependencies: None, policy: None, path_to_skills_md: test_path_buf("/tmp/repo/google-calendar/SKILL.md").abs(), - scope: codex_protocol::protocol::SkillScope::Repo, + scope: crate::test_support::skill_scope_repo(), }])); composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { config_name: "google-calendar@debug".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 22f4a16a64..52ae811226 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -15,11 +15,11 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; +use crate::app_command::AppCommand as Op; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::MentionBinding; use crate::mention_codec::decode_history_mentions; -use codex_protocol::protocol::Op; use codex_protocol::user_input::TextElement; /// A composer history entry that can rehydrate draft state. @@ -598,10 +598,7 @@ impl ChatComposerHistory { boundary_if_exhausted, }); } - app_event_tx.send(AppEvent::CodexOp(Op::GetHistoryEntryRequest { - offset, - log_id, - })); + app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(offset, log_id))); return HistorySearchResult::Pending; } @@ -719,10 +716,7 @@ impl ChatComposerHistory { self.last_history_text = Some(entry.text.clone()); return Some(entry); } else if let Some(log_id) = self.history_log_id { - app_event_tx.send(AppEvent::CodexOp(Op::GetHistoryEntryRequest { - offset: global_idx, - log_id, - })); + app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(global_idx, log_id))); } None } diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 5de9b64379..0ba52ad75d 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -2,6 +2,8 @@ use std::collections::HashSet; use std::collections::VecDeque; use std::path::PathBuf; +#[cfg(test)] +use crate::app_command::AppCommand as Op; use codex_app_server_protocol::McpElicitationEnumSchema; use codex_app_server_protocol::McpElicitationPrimitiveSchema; use codex_app_server_protocol::McpElicitationSingleSelectEnumSchema; @@ -12,8 +14,6 @@ use codex_protocol::approvals::ElicitationAction; use codex_protocol::approvals::ElicitationRequest; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::mcp::RequestId as McpRequestId; -#[cfg(test)] -use codex_protocol::protocol::Op; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d3274e96dd..7947d939c5 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1567,14 +1567,13 @@ impl Renderable for BottomPane { mod tests { use super::*; use crate::app::app_server_requests::ResolvedAppServerRequest; + use crate::app_command::AppCommand as Op; use crate::app_event::AppEvent; use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; - use codex_protocol::protocol::Op; - use codex_protocol::protocol::ReviewDecision; - use codex_protocol::protocol::SkillScope; + use codex_app_server_protocol::CommandExecutionApprovalDecision; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -1634,8 +1633,8 @@ mod tests { command: vec!["echo".into(), "ok".into()], reason: None, available_decisions: vec![ - codex_protocol::protocol::ReviewDecision::Approved, - codex_protocol::protocol::ReviewDecision::Abort, + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, ], network_approval_context: None, additional_permissions: None, @@ -1897,7 +1896,10 @@ mod tests { approval_decision = Some(decision); } } - assert_eq!(approval_decision, Some(ReviewDecision::Approved)); + assert_eq!( + approval_decision, + Some(CommandExecutionApprovalDecision::Accept) + ); } #[test] @@ -2354,7 +2356,7 @@ mod tests { dependencies: None, policy: None, path_to_skills_md: test_path_buf("/tmp/test-skill/SKILL.md").abs(), - scope: SkillScope::User, + scope: crate::test_support::skill_scope_user(), }]), }); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index c11b5c1ed5..e865a69d56 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -32,7 +32,7 @@ use crate::history_cell; use crate::render::renderable::Renderable; #[cfg(test)] -use codex_protocol::protocol::Op; +use crate::app_command::AppCommand as Op; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputResponse; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e7a58d448a..a4413e8975 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -30,7 +30,6 @@ //! here. That split lets the composer stage a recall entry before clearing input while this module //! records the attempted slash command after dispatch just like ordinary submitted text. use std::collections::BTreeMap; -use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -43,13 +42,52 @@ use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; -use self::realtime::PendingSteerCompareKey; +#[cfg(test)] +use self::test_events::AgentMessageDeltaEvent; +#[cfg(test)] +use self::test_events::AgentMessageEvent; +#[cfg(test)] +use self::test_events::AgentReasoningDeltaEvent; +#[cfg(test)] +use self::test_events::AgentReasoningEvent; +#[cfg(test)] +use self::test_events::AgentReasoningRawContentDeltaEvent; +#[cfg(test)] +use self::test_events::AgentReasoningRawContentEvent; +#[cfg(test)] +use self::test_events::BackgroundEventEvent; +#[cfg(test)] +use self::test_events::ErrorEvent; +#[cfg(test)] +use self::test_events::Event; +#[cfg(test)] +use self::test_events::EventMsg; +#[cfg(test)] +use self::test_events::ExitedReviewModeEvent; +#[cfg(test)] +use self::test_events::McpListToolsResponseEvent; +#[cfg(test)] +use self::test_events::SessionConfiguredEvent; +#[cfg(test)] +use self::test_events::StreamErrorEvent; +#[cfg(test)] +use self::test_events::TurnCompleteEvent; +#[cfg(test)] +use self::test_events::TurnDiffEvent; +#[cfg(test)] +use self::test_events::UndoCompletedEvent; +#[cfg(test)] +use self::test_events::UndoStartedEvent; +#[cfg(test)] +use self::test_events::WarningEvent; use crate::app::app_server_requests::ResolvedAppServerRequest; use crate::app_command::AppCommand; +use crate::app_event::HistoryLookupResponse; use crate::app_event::RealtimeAudioDeviceKind; use crate::app_server_approval_conversions::file_update_changes_to_core; use crate::app_server_approval_conversions::network_approval_context_to_core; -use crate::app_server_session::ThreadSessionState; +use crate::approval_events::ApplyPatchApprovalRequestEvent; +use crate::approval_events::ExecApprovalRequestEvent; #[cfg(not(target_os = "linux"))] use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; @@ -68,6 +106,9 @@ use crate::mention_codec::LinkedMention; use crate::mention_codec::encode_history_mentions; use crate::model_catalog::ModelCatalog; use crate::multi_agents; +use crate::multi_agents::AgentMetadata; +use crate::session_state::SessionNetworkProxyRuntime; +use crate::session_state::ThreadSessionState; use crate::status::RateLimitWindowDisplay; use crate::status::StatusAccountDisplay; use crate::status::StatusHistoryHandle; @@ -78,29 +119,55 @@ use crate::terminal_title::SetTerminalTitleResult; use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; +use crate::token_usage::TokenUsage; +use crate::token_usage::TokenUsageInfo; +use crate::tool_activity::ExecCommandBeginEvent; +use crate::tool_activity::ExecCommandEndEvent; +use crate::tool_activity::ExecCommandOutputDeltaEvent; +use crate::tool_activity::HookCompletedEvent; +use crate::tool_activity::HookStartedEvent; +use crate::tool_activity::ImageGenerationBeginEvent; +use crate::tool_activity::ImageGenerationEndEvent; +use crate::tool_activity::McpInvocation; +use crate::tool_activity::McpToolCallBeginEvent; +use crate::tool_activity::McpToolCallEndEvent; +use crate::tool_activity::PatchApplyBeginEvent; +use crate::tool_activity::PatchApplyEndEvent; +use crate::tool_activity::TerminalInteractionEvent; +use crate::tool_activity::ViewImageToolCallEvent; +use crate::tool_activity::WebSearchBeginEvent; +use crate::tool_activity::WebSearchEndEvent; +use crate::turn_state::TurnAbortReason; use crate::version::CODEX_CLI_VERSION; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppSummary; +#[cfg(test)] +use codex_app_server_protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; -use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; -use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus; use codex_app_server_protocol::CollabAgentTool; use codex_app_server_protocol::CollabAgentToolCallStatus; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; +use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::CreditsSnapshot; use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::GuardianApprovalReviewAction; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; -use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusDetail; -use codex_app_server_protocol::McpServerStatusUpdatedNotification; +#[cfg(test)] +use codex_app_server_protocol::ModelVerification as CoreModelVerification; use codex_app_server_protocol::ModelVerification as AppServerModelVerification; +use codex_app_server_protocol::RateLimitReachedType; +use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::ReviewTarget; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SkillMetadata as ProtocolSkillMetadata; +use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoal as AppThreadGoal; use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; use codex_app_server_protocol::ThreadItem; @@ -130,6 +197,10 @@ use codex_plugin::PluginCapabilitySummary; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::approvals::GuardianAssessmentAction; +use codex_protocol::approvals::GuardianAssessmentDecisionSource; +use codex_protocol::approvals::GuardianAssessmentEvent; +use codex_protocol::approvals::GuardianAssessmentStatus; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; @@ -146,89 +217,6 @@ use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus; -#[cfg(test)] -use codex_protocol::protocol::AgentMessageDeltaEvent; -#[cfg(test)] -use codex_protocol::protocol::AgentMessageEvent; -#[cfg(test)] -use codex_protocol::protocol::AgentReasoningDeltaEvent; -#[cfg(test)] -use codex_protocol::protocol::AgentReasoningEvent; -#[cfg(test)] -use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent; -#[cfg(test)] -use codex_protocol::protocol::AgentReasoningRawContentEvent; -use codex_protocol::protocol::AgentStatus; -use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; -#[cfg(test)] -use codex_protocol::protocol::BackgroundEventEvent; -#[cfg(test)] -use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; -use codex_protocol::protocol::CollabAgentRef; -#[cfg(test)] -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentStatusEntry; -use codex_protocol::protocol::CreditsSnapshot; -use codex_protocol::protocol::DeprecationNoticeEvent; -#[cfg(test)] -use codex_protocol::protocol::ErrorEvent; -#[cfg(test)] -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecApprovalRequestEvent; -use codex_protocol::protocol::ExecCommandBeginEvent; -use codex_protocol::protocol::ExecCommandEndEvent; -use codex_protocol::protocol::ExecCommandOutputDeltaEvent; -use codex_protocol::protocol::ExecCommandSource; -#[cfg(test)] -use codex_protocol::protocol::ExitedReviewModeEvent; -use codex_protocol::protocol::GuardianAssessmentAction; -use codex_protocol::protocol::GuardianAssessmentDecisionSource; -use codex_protocol::protocol::GuardianAssessmentEvent; -use codex_protocol::protocol::GuardianAssessmentStatus; -use codex_protocol::protocol::ImageGenerationBeginEvent; -use codex_protocol::protocol::ImageGenerationEndEvent; -use codex_protocol::protocol::ListSkillsResponseEvent; -#[cfg(test)] -use codex_protocol::protocol::McpListToolsResponseEvent; -#[cfg(test)] -use codex_protocol::protocol::McpStartupCompleteEvent; -use codex_protocol::protocol::McpStartupStatus; -#[cfg(test)] -use codex_protocol::protocol::McpStartupUpdateEvent; -use codex_protocol::protocol::McpToolCallBeginEvent; -use codex_protocol::protocol::McpToolCallEndEvent; -#[cfg(test)] -use codex_protocol::protocol::ModelVerification as CoreModelVerification; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::PatchApplyBeginEvent; -use codex_protocol::protocol::RateLimitReachedType; -use codex_protocol::protocol::RateLimitSnapshot; -use codex_protocol::protocol::ReviewRequest; -use codex_protocol::protocol::ReviewTarget; -use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; -#[cfg(test)] -use codex_protocol::protocol::StreamErrorEvent; -use codex_protocol::protocol::TerminalInteractionEvent; -#[cfg(test)] -use codex_protocol::protocol::ThreadGoalStatus as ProtocolThreadGoalStatus; -use codex_protocol::protocol::TokenUsage; -use codex_protocol::protocol::TokenUsageInfo; -use codex_protocol::protocol::TurnAbortReason; -#[cfg(test)] -use codex_protocol::protocol::TurnCompleteEvent; -#[cfg(test)] -use codex_protocol::protocol::TurnDiffEvent; -#[cfg(test)] -use codex_protocol::protocol::UndoCompletedEvent; -#[cfg(test)] -use codex_protocol::protocol::UndoStartedEvent; -use codex_protocol::protocol::UserMessageEvent; -use codex_protocol::protocol::ViewImageToolCallEvent; -#[cfg(test)] -use codex_protocol::protocol::WarningEvent; -use codex_protocol::protocol::WebSearchBeginEvent; -use codex_protocol::protocol::WebSearchEndEvent; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputQuestionOption; @@ -363,8 +351,6 @@ use crate::exec_command::split_command_string; use crate::exec_command::strip_bash_lc_and_escape; use crate::get_git_diff::get_git_diff; use crate::history_cell; -#[cfg(test)] -use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::HookCell; use crate::history_cell::McpToolCallCell; @@ -395,6 +381,8 @@ mod goal_menu; mod interrupts; use self::interrupts::InterruptManager; mod keymap_picker; +mod mcp_startup; +use self::mcp_startup::McpStartupStatus; mod session_header; use self::session_header::SessionHeader; mod skills; @@ -408,12 +396,15 @@ mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; mod realtime; use self::realtime::RealtimeConversationUiState; -use self::realtime::RenderedUserMessageEvent; mod reasoning_shortcuts; mod side; mod status_surfaces; use self::status_surfaces::CachedProjectRootName; use self::status_surfaces::TerminalTitleStatusKind; +mod user_messages; +use self::user_messages::PendingSteerCompareKey; +use self::user_messages::RenderedUserMessageEvent; +use self::user_messages::UserMessageEvent; use crate::streaming::chunking::AdaptiveChunkingPolicy; use crate::streaming::commit_tick::CommitTickScope; use crate::streaming::commit_tick::run_commit_tick; @@ -421,6 +412,7 @@ use crate::streaming::controller::PlanStreamController; use crate::streaming::controller::StreamController; use chrono::Local; +use codex_app_server_protocol::AskForApproval; use codex_file_search::FileMatch; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::InputModality; @@ -428,7 +420,6 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol::AskForApproval; use codex_utils_approval_presets::ApprovalPreset; use codex_utils_approval_presets::builtin_approval_presets; use strum::IntoEnumIterator; @@ -870,7 +861,7 @@ pub(crate) struct ChatWidget { /// emitted. saw_copy_source_this_turn: bool, running_commands: HashMap, - collab_agent_metadata: HashMap, + collab_agent_metadata: HashMap, pending_collab_spawn_requests: HashMap, suppressed_exec_calls: HashSet, skills_all: Vec, @@ -1039,7 +1030,7 @@ pub(crate) struct ChatWidget { // Instruction source files loaded for the current session, supplied by app-server. instruction_source_paths: Vec, // Runtime network proxy bind addresses from SessionConfigured. - session_network_proxy: Option, + session_network_proxy: Option, // Shared latch so we only warn once about invalid status-line item IDs. status_line_invalid_items_warned: Arc, // Shared latch so we only warn once about invalid terminal-title item IDs. @@ -1078,21 +1069,9 @@ pub(crate) struct ChatWidget { last_non_retry_error: Option<(String, String)>, } -/// Cached nickname and role for a collab agent thread, used to attach human-readable labels to -/// rendered tool-call items. -/// -/// Populated externally by `App` via `set_collab_agent_metadata` and consulted by the -/// notification-to-core-event conversion helpers. Defaults to empty so that missing metadata -/// degrades to the previous behavior of showing raw thread ids. -#[derive(Clone, Debug, Default)] -struct CollabAgentMetadata { - agent_nickname: Option, - agent_role: Option, -} - #[cfg_attr(not(test), allow(dead_code))] enum CodexOpTarget { - Direct(UnboundedSender), + Direct(UnboundedSender), AppEvent, } @@ -1638,51 +1617,28 @@ impl ThreadItemRenderSource { } } -fn thread_session_state_to_legacy_event( - session: ThreadSessionState, -) -> codex_protocol::protocol::SessionConfiguredEvent { - codex_protocol::protocol::SessionConfiguredEvent { - session_id: session.thread_id, - forked_from_id: session.forked_from_id, - thread_name: session.thread_name, - model: session.model, - model_provider_id: session.model_provider_id, - service_tier: session.service_tier, - approval_policy: session.approval_policy, - approvals_reviewer: session.approvals_reviewer, - permission_profile: session.permission_profile, - cwd: session.cwd, - reasoning_effort: session.reasoning_effort, - history_log_id: session.history_log_id, - history_entry_count: usize::try_from(session.history_entry_count).unwrap_or(usize::MAX), - initial_messages: None, - network_proxy: session.network_proxy, - rollout_path: session.rollout_path, - } -} - fn hook_output_entry_from_notification( entry: codex_app_server_protocol::HookOutputEntry, -) -> codex_protocol::protocol::HookOutputEntry { - codex_protocol::protocol::HookOutputEntry { - kind: entry.kind.to_core(), +) -> codex_app_server_protocol::HookOutputEntry { + codex_app_server_protocol::HookOutputEntry { + kind: entry.kind, text: entry.text, } } fn hook_run_summary_from_notification( run: codex_app_server_protocol::HookRunSummary, -) -> codex_protocol::protocol::HookRunSummary { - codex_protocol::protocol::HookRunSummary { +) -> codex_app_server_protocol::HookRunSummary { + codex_app_server_protocol::HookRunSummary { id: run.id, - event_name: run.event_name.to_core(), - handler_type: run.handler_type.to_core(), - execution_mode: run.execution_mode.to_core(), - scope: run.scope.to_core(), + event_name: run.event_name, + handler_type: run.handler_type, + execution_mode: run.execution_mode, + scope: run.scope, source_path: run.source_path, - source: run.source.to_core(), + source: run.source, display_order: run.display_order, - status: run.status.to_core(), + status: run.status, status_message: run.status_message, started_at: run.started_at, completed_at: run.completed_at, @@ -1697,8 +1653,8 @@ fn hook_run_summary_from_notification( fn hook_started_event_from_notification( notification: codex_app_server_protocol::HookStartedNotification, -) -> codex_protocol::protocol::HookStartedEvent { - codex_protocol::protocol::HookStartedEvent { +) -> HookStartedEvent { + HookStartedEvent { turn_id: notification.turn_id, run: hook_run_summary_from_notification(notification.run), } @@ -1706,8 +1662,8 @@ fn hook_started_event_from_notification( fn hook_completed_event_from_notification( notification: codex_app_server_protocol::HookCompletedNotification, -) -> codex_protocol::protocol::HookCompletedEvent { - codex_protocol::protocol::HookCompletedEvent { +) -> HookCompletedEvent { + HookCompletedEvent { turn_id: notification.turn_id, run: hook_run_summary_from_notification(notification.run), } @@ -1761,26 +1717,26 @@ fn exec_approval_request_from_params( .into_iter() .map(|decision| match decision { codex_app_server_protocol::CommandExecutionApprovalDecision::Accept => { - codex_protocol::protocol::ReviewDecision::Approved + crate::approval_display::ReviewDecision::Approved } codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession => { - codex_protocol::protocol::ReviewDecision::ApprovedForSession + crate::approval_display::ReviewDecision::ApprovedForSession } codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { execpolicy_amendment, - } => codex_protocol::protocol::ReviewDecision::ApprovedExecpolicyAmendment { + } => crate::approval_display::ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment: execpolicy_amendment.into_core(), }, codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { network_policy_amendment, - } => codex_protocol::protocol::ReviewDecision::NetworkPolicyAmendment { + } => crate::approval_display::ReviewDecision::NetworkPolicyAmendment { network_policy_amendment: network_policy_amendment.into_core(), }, codex_app_server_protocol::CommandExecutionApprovalDecision::Decline => { - codex_protocol::protocol::ReviewDecision::Denied + crate::approval_display::ReviewDecision::Denied } codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel => { - codex_protocol::protocol::ReviewDecision::Abort + crate::approval_display::ReviewDecision::Abort } }) .collect() @@ -1806,92 +1762,6 @@ fn patch_approval_request_from_params( } } -fn app_server_collab_thread_id_to_core(thread_id: &str) -> Option { - match ThreadId::from_string(thread_id) { - Ok(thread_id) => Some(thread_id), - Err(err) => { - warn!("ignoring collab tool-call item with invalid thread id {thread_id}: {err}"); - None - } - } -} - -fn app_server_collab_state_to_core(state: &AppServerCollabAgentState) -> AgentStatus { - match state.status { - AppServerCollabAgentStatus::PendingInit => AgentStatus::PendingInit, - AppServerCollabAgentStatus::Running => AgentStatus::Running, - AppServerCollabAgentStatus::Interrupted => AgentStatus::Interrupted, - AppServerCollabAgentStatus::Completed => AgentStatus::Completed(state.message.clone()), - AppServerCollabAgentStatus::Errored => AgentStatus::Errored( - state - .message - .clone() - .unwrap_or_else(|| "Agent errored".into()), - ), - AppServerCollabAgentStatus::Shutdown => AgentStatus::Shutdown, - AppServerCollabAgentStatus::NotFound => AgentStatus::NotFound, - } -} - -/// Converts app-server collab agent states into the core protocol representation, enriching each -/// entry with cached nickname and role metadata so rendered items show human-readable names. -fn app_server_collab_agent_statuses_to_core( - receiver_thread_ids: &[String], - agents_states: &HashMap, - collab_agent_metadata: &HashMap, -) -> (Vec, HashMap) { - let mut agent_statuses = Vec::new(); - let mut statuses = HashMap::new(); - - for receiver_thread_id in receiver_thread_ids { - let Some(thread_id) = app_server_collab_thread_id_to_core(receiver_thread_id) else { - continue; - }; - let Some(agent_state) = agents_states.get(receiver_thread_id) else { - continue; - }; - let status = app_server_collab_state_to_core(agent_state); - let metadata = collab_agent_metadata - .get(&thread_id) - .cloned() - .unwrap_or_default(); - agent_statuses.push(CollabAgentStatusEntry { - thread_id, - agent_nickname: metadata.agent_nickname, - agent_role: metadata.agent_role, - status: status.clone(), - }); - statuses.insert(thread_id, status); - } - - (agent_statuses, statuses) -} - -/// Builds `CollabAgentRef` entries for every valid receiver thread, attaching cached metadata. -/// -/// Used when converting collab `Wait` tool-call items so the rendered waiting list shows agent -/// names instead of bare thread ids. -fn app_server_collab_receiver_agent_refs( - receiver_thread_ids: &[String], - collab_agent_metadata: &HashMap, -) -> Vec { - receiver_thread_ids - .iter() - .filter_map(|thread_id| { - let thread_id = app_server_collab_thread_id_to_core(thread_id)?; - let metadata = collab_agent_metadata - .get(&thread_id) - .cloned() - .unwrap_or_default(); - Some(CollabAgentRef { - thread_id, - agent_nickname: metadata.agent_nickname, - agent_role: metadata.agent_role, - }) - }) - .collect() -} - fn request_permissions_from_params( params: codex_app_server_protocol::PermissionsRequestApprovalParams, ) -> RequestPermissionsEvent { @@ -1987,7 +1857,7 @@ impl ChatWidget { ) { self.collab_agent_metadata.insert( thread_id, - CollabAgentMetadata { + AgentMetadata { agent_nickname, agent_role, }, @@ -1995,7 +1865,7 @@ impl ChatWidget { } /// Returns the cached metadata for a thread, defaulting to empty if none has been registered. - fn collab_agent_metadata(&self, thread_id: ThreadId) -> CollabAgentMetadata { + fn collab_agent_metadata(&self, thread_id: ThreadId) -> AgentMetadata { self.collab_agent_metadata .get(&thread_id) .cloned() @@ -2329,79 +2199,84 @@ impl ChatWidget { // --- Small event handlers --- #[cfg(test)] - fn on_session_configured(&mut self, event: codex_protocol::protocol::SessionConfiguredEvent) { + fn on_session_configured(&mut self, event: SessionConfiguredEvent) { + let (session, initial_messages) = event.into_session(); self.on_session_configured_with_display_and_fork_parent_title( - event, + session, SessionConfiguredDisplay::Normal, /*fork_parent_title*/ None, + initial_messages, ); } fn on_session_configured_with_display_and_fork_parent_title( &mut self, - event: codex_protocol::protocol::SessionConfiguredEvent, + session: ThreadSessionState, display: SessionConfiguredDisplay, fork_parent_title: Option, + #[cfg(test)] initial_messages: Option>, ) { self.last_agent_markdown = None; self.agent_turn_markdowns.clear(); self.visible_user_turn_count = 0; self.copy_history_evicted_by_rollback = false; self.saw_copy_source_this_turn = false; + let history_entry_count = + usize::try_from(session.history_entry_count).unwrap_or(usize::MAX); self.bottom_pane - .set_history_metadata(event.history_log_id, event.history_entry_count); + .set_history_metadata(session.history_log_id, history_entry_count); self.set_skills(/*skills*/ None); - self.session_network_proxy = event.network_proxy.clone(); + self.session_network_proxy = session.network_proxy.clone(); let previous_thread_id = self.thread_id; - self.thread_id = Some(event.session_id); + self.thread_id = Some(session.thread_id); if previous_thread_id != self.thread_id { self.recent_auto_review_denials = RecentAutoReviewDenials::default(); } self.refresh_plan_mode_nudge(); self.last_turn_id = None; - self.thread_name = event.thread_name.clone(); + self.thread_name = session.thread_name.clone(); self.current_goal_status_indicator = None; self.current_goal_status = None; self.goal_status_active_turn_started_at = None; self.budget_limited_turn_ids.clear(); self.update_collaboration_mode_indicator(); - self.forked_from = event.forked_from_id; - self.current_rollout_path = event.rollout_path.clone(); - self.current_cwd = Some(event.cwd.to_path_buf()); - self.config.cwd = event.cwd.clone(); - self.effective_service_tier = event.service_tier; + self.forked_from = session.forked_from_id; + self.current_rollout_path = session.rollout_path.clone(); + self.current_cwd = Some(session.cwd.to_path_buf()); + self.config.cwd = session.cwd.clone(); + self.effective_service_tier = session.service_tier; if let Err(err) = self .config .permissions .approval_policy - .set(event.approval_policy) + .set(session.approval_policy.to_core()) { tracing::warn!(%err, "failed to sync approval_policy from SessionConfigured"); self.config.permissions.approval_policy = - Constrained::allow_only(event.approval_policy); + Constrained::allow_only(session.approval_policy.to_core()); } let permission_sync = self .config .permissions - .set_permission_profile(event.permission_profile.clone()); + .set_permission_profile(session.permission_profile.clone()); if let Err(err) = permission_sync { tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); self.config.permissions.permission_profile = - Constrained::allow_only(event.permission_profile.clone()); + Constrained::allow_only(session.permission_profile.clone()); } - self.config.approvals_reviewer = event.approvals_reviewer; + self.config.approvals_reviewer = session.approvals_reviewer; self.status_line_project_root_name_cache = None; - let forked_from_id = event.forked_from_id; - let model_for_header = event.model.clone(); + let forked_from_id = session.forked_from_id; + let model_for_header = session.model.clone(); self.session_header.set_model(&model_for_header); self.current_collaboration_mode = self.current_collaboration_mode.with_updates( Some(model_for_header.clone()), - Some(event.reasoning_effort), + Some(session.reasoning_effort), /*developer_instructions*/ None, ); if let Some(mask) = self.active_collaboration_mask.as_mut() { mask.model = Some(model_for_header.clone()); - mask.reasoning_effort = Some(event.reasoning_effort); + mask.reasoning_effort = Some(session.reasoning_effort); } self.refresh_model_display(); self.refresh_status_surfaces(); @@ -2413,13 +2288,11 @@ impl ChatWidget { if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = - self.should_show_fast_status(&model_for_header, event.service_tier); - #[cfg(test)] - let initial_messages = event.initial_messages.clone(); + self.should_show_fast_status(&model_for_header, session.service_tier); let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, - event, + &session, self.show_welcome_banner, startup_tooltip_override, self.plan_type, @@ -2475,18 +2348,24 @@ impl ChatWidget { self.instruction_source_paths = session.instruction_source_paths.clone(); let fork_parent_title = session.fork_parent_title.clone(); self.on_session_configured_with_display_and_fork_parent_title( - thread_session_state_to_legacy_event(session), + session, SessionConfiguredDisplay::Normal, fork_parent_title, + #[cfg(test)] + /*initial_messages*/ + None, ); } pub(crate) fn handle_thread_session_quiet(&mut self, session: ThreadSessionState) { self.instruction_source_paths = session.instruction_source_paths.clone(); self.on_session_configured_with_display_and_fork_parent_title( - thread_session_state_to_legacy_event(session), + session, SessionConfiguredDisplay::Quiet, /*fork_parent_title*/ None, + #[cfg(test)] + /*initial_messages*/ + None, ); } @@ -2494,9 +2373,12 @@ impl ChatWidget { self.instruction_source_paths = session.instruction_source_paths.clone(); let fork_parent_title = session.fork_parent_title.clone(); self.on_session_configured_with_display_and_fork_parent_title( - thread_session_state_to_legacy_event(session), + session, SessionConfiguredDisplay::SideConversation, fork_parent_title, + #[cfg(test)] + /*initial_messages*/ + None, ); } @@ -2531,13 +2413,13 @@ impl ChatWidget { ))); } - fn on_thread_name_updated(&mut self, event: codex_protocol::protocol::ThreadNameUpdatedEvent) { - if self.thread_id == Some(event.thread_id) { - if let Some(name) = event.thread_name.as_deref() { + fn on_thread_name_updated(&mut self, thread_id: ThreadId, thread_name: Option) { + if self.thread_id == Some(thread_id) { + if let Some(name) = thread_name.as_deref() { let cell = Self::rename_confirmation_cell(name, self.thread_id); self.add_boxed_history(Box::new(cell)); } - self.thread_name = event.thread_name; + self.thread_name = thread_name; self.refresh_status_surfaces(); self.request_redraw(); self.maybe_send_next_queued_input(); @@ -3245,16 +3127,19 @@ impl ChatWidget { snapshot .secondary .as_ref() - .map(|window| window.used_percent), + .map(|window| f64::from(window.used_percent)), snapshot .secondary .as_ref() - .and_then(|window| window.window_minutes), - snapshot.primary.as_ref().map(|window| window.used_percent), + .and_then(|window| window.window_duration_mins), snapshot .primary .as_ref() - .and_then(|window| window.window_minutes), + .map(|window| f64::from(window.used_percent)), + snapshot + .primary + .as_ref() + .and_then(|window| window.window_duration_mins), ) } else { vec![] @@ -3264,12 +3149,12 @@ impl ChatWidget { && (snapshot .secondary .as_ref() - .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) .unwrap_or(false) || snapshot .primary .as_ref() - .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) .unwrap_or(false)); let has_workspace_credits = snapshot @@ -3471,249 +3356,6 @@ impl ChatWidget { } } - /// Record one MCP startup update, promoting it into either the active startup - /// round or a buffered "next" round. - /// - /// This path has to deal with lossy app-server delivery. After - /// `finish_mcp_startup()` or `finish_mcp_startup_after_lag()`, we briefly - /// ignore incoming updates so stale events from the just-finished round do not - /// reopen startup. While that guard is active we buffer updates for a possible - /// next round, and only reactivate once the buffered set is coherent enough to - /// treat as a fresh startup round. - fn update_mcp_startup_status( - &mut self, - server: String, - status: McpStartupStatus, - complete_when_settled: bool, - ) { - let mut activated_pending_round = false; - let startup_status = if self.mcp_startup_ignore_updates_until_next_start { - // Ignore-mode buffers the next plausible round so stale post-finish - // updates cannot immediately reopen startup. A fresh `Starting` - // update resets the buffer only if we have not already seen a - // pending-round `Starting`; this preserves valid interleavings like - // `alpha: Starting -> alpha: Ready -> beta: Starting`. - if matches!(status, McpStartupStatus::Starting) - && !self.mcp_startup_pending_next_round_saw_starting - { - self.mcp_startup_pending_next_round.clear(); - self.mcp_startup_allow_terminal_only_next_round = false; - } - self.mcp_startup_pending_next_round_saw_starting |= - matches!(status, McpStartupStatus::Starting); - self.mcp_startup_pending_next_round.insert(server, status); - let Some(expected_servers) = &self.mcp_startup_expected_servers else { - return; - }; - let saw_full_round = expected_servers.is_empty() - || expected_servers - .iter() - .all(|name| self.mcp_startup_pending_next_round.contains_key(name)); - let saw_starting = self - .mcp_startup_pending_next_round - .values() - .any(|state| matches!(state, McpStartupStatus::Starting)); - if !(saw_full_round - && (saw_starting || self.mcp_startup_allow_terminal_only_next_round)) - { - return; - } - - // The buffered map now looks like a complete next round, so promote it - // to the active round and resume normal completion tracking. - self.mcp_startup_ignore_updates_until_next_start = false; - self.mcp_startup_allow_terminal_only_next_round = false; - self.mcp_startup_pending_next_round_saw_starting = false; - activated_pending_round = true; - std::mem::take(&mut self.mcp_startup_pending_next_round) - } else { - // Normal path: fold the update into the active round and surface - // per-server failures immediately. - let mut startup_status = self.mcp_startup_status.take().unwrap_or_default(); - if let McpStartupStatus::Failed { error } = &status { - self.on_warning(error); - } - startup_status.insert(server, status); - startup_status - }; - if activated_pending_round { - // A promoted buffered round may already contain terminal failures. - for state in startup_status.values() { - if let McpStartupStatus::Failed { error } = state { - self.on_warning(error); - } - } - } - self.mcp_startup_status = Some(startup_status); - self.update_task_running_state(); - - // App-server-backed startup completes when every expected server has - // reported a non-Starting status. Lag handling can force an earlier - // settle via `finish_mcp_startup_after_lag()`. - if complete_when_settled - && let Some(current) = &self.mcp_startup_status - && let Some(expected_servers) = &self.mcp_startup_expected_servers - && !current.is_empty() - && expected_servers - .iter() - .all(|name| current.contains_key(name)) - && current - .values() - .all(|state| !matches!(state, McpStartupStatus::Starting)) - { - let mut failed = Vec::new(); - let mut cancelled = Vec::new(); - for (name, state) in current { - match state { - McpStartupStatus::Ready => {} - McpStartupStatus::Failed { .. } => failed.push(name.clone()), - McpStartupStatus::Cancelled => cancelled.push(name.clone()), - McpStartupStatus::Starting => {} - } - } - failed.sort(); - cancelled.sort(); - self.finish_mcp_startup(failed, cancelled); - return; - } - if let Some(current) = &self.mcp_startup_status { - // Otherwise keep the status header focused on the remaining - // in-progress servers for the active round. - let total = current.len(); - let mut starting: Vec<_> = current - .iter() - .filter_map(|(name, state)| { - if matches!(state, McpStartupStatus::Starting) { - Some(name) - } else { - None - } - }) - .collect(); - starting.sort(); - if let Some(first) = starting.first() { - let completed = total.saturating_sub(starting.len()); - let max_to_show = 3; - let mut to_show: Vec = starting - .iter() - .take(max_to_show) - .map(ToString::to_string) - .collect(); - if starting.len() > max_to_show { - to_show.push("…".to_string()); - } - let header = if total > 1 { - format!( - "Starting MCP servers ({completed}/{total}): {}", - to_show.join(", ") - ) - } else { - format!("Booting MCP server: {first}") - }; - self.set_status_header(header); - } - } - self.request_redraw(); - } - - pub(crate) fn set_mcp_startup_expected_servers(&mut self, server_names: I) - where - I: IntoIterator, - { - self.mcp_startup_expected_servers = Some(server_names.into_iter().collect()); - } - - #[cfg(test)] - fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { - self.update_mcp_startup_status(ev.server, ev.status, /*complete_when_settled*/ false); - } - - fn finish_mcp_startup(&mut self, failed: Vec, cancelled: Vec) { - if !cancelled.is_empty() { - self.on_warning(format!( - "MCP startup interrupted. The following servers were not initialized: {}", - cancelled.join(", ") - )); - } - let mut parts = Vec::new(); - if !failed.is_empty() { - parts.push(format!("failed: {}", failed.join(", "))); - } - if !parts.is_empty() { - self.on_warning(format!("MCP startup incomplete ({})", parts.join("; "))); - } - - self.mcp_startup_status = None; - self.mcp_startup_ignore_updates_until_next_start = true; - self.mcp_startup_allow_terminal_only_next_round = false; - self.mcp_startup_pending_next_round.clear(); - self.mcp_startup_pending_next_round_saw_starting = false; - self.update_task_running_state(); - self.maybe_send_next_queued_input(); - self.request_redraw(); - } - - pub(crate) fn finish_mcp_startup_after_lag(&mut self) { - if self.mcp_startup_ignore_updates_until_next_start { - if self.mcp_startup_pending_next_round.is_empty() { - self.mcp_startup_pending_next_round_saw_starting = false; - } - self.mcp_startup_allow_terminal_only_next_round = true; - } - - let Some(current) = &self.mcp_startup_status else { - return; - }; - - let mut failed = Vec::new(); - let mut cancelled = Vec::new(); - - let mut server_names: BTreeSet = current.keys().cloned().collect(); - if let Some(expected_servers) = &self.mcp_startup_expected_servers { - server_names.extend(expected_servers.iter().cloned()); - } - - for name in server_names { - match current.get(&name) { - Some(McpStartupStatus::Ready) => {} - Some(McpStartupStatus::Failed { .. }) => failed.push(name), - Some(McpStartupStatus::Cancelled | McpStartupStatus::Starting) | None => { - cancelled.push(name); - } - } - } - - failed.sort(); - failed.dedup(); - cancelled.sort(); - cancelled.dedup(); - self.finish_mcp_startup(failed, cancelled); - } - - #[cfg(test)] - fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { - let failed = ev.failed.into_iter().map(|f| f.server).collect(); - self.finish_mcp_startup(failed, ev.cancelled); - } - - fn on_mcp_server_status_updated(&mut self, notification: McpServerStatusUpdatedNotification) { - let status = match notification.status { - McpServerStartupState::Starting => McpStartupStatus::Starting, - McpServerStartupState::Ready => McpStartupStatus::Ready, - McpServerStartupState::Failed => McpStartupStatus::Failed { - error: notification.error.unwrap_or_else(|| { - format!("MCP client for `{}` failed to start", notification.name) - }), - }, - McpServerStartupState::Cancelled => McpStartupStatus::Cancelled, - }; - self.update_mcp_startup_status( - notification.name, - status, - /*complete_when_settled*/ true, - ); - } - /// Handle a turn aborted due to user interrupt (Esc), budget exhaustion, /// or review completion. /// When there are queued user messages, restore them into the composer @@ -4122,7 +3764,7 @@ impl ChatWidget { let cell = if let Some(command) = guardian_command(&ev.action) { history_cell::new_approval_decision_cell( command, - codex_protocol::protocol::ReviewDecision::Approved, + crate::approval_display::ReviewDecision::Approved, history_cell::ApprovalDecisionActor::Guardian, ) } else if let Some(summary) = guardian_action_summary(&ev.action) { @@ -4142,7 +3784,7 @@ impl ChatWidget { let cell = if let Some(command) = guardian_command(&ev.action) { history_cell::new_approval_decision_cell( command, - codex_protocol::protocol::ReviewDecision::TimedOut, + crate::approval_display::ReviewDecision::TimedOut, history_cell::ApprovalDecisionActor::Guardian, ) } else { @@ -4186,7 +3828,7 @@ impl ChatWidget { let cell = if let Some(command) = guardian_command(&ev.action) { history_cell::new_approval_decision_cell( command, - codex_protocol::protocol::ReviewDecision::Denied, + crate::approval_display::ReviewDecision::Denied, history_cell::ApprovalDecisionActor::Guardian, ) } else { @@ -4368,7 +4010,7 @@ impl ChatWidget { self.request_redraw(); } - fn on_patch_apply_end(&mut self, event: codex_protocol::protocol::PatchApplyEndEvent) { + fn on_patch_apply_end(&mut self, event: PatchApplyEndEvent) { let ev2 = event.clone(); self.defer_or_handle( |q| q.push_patch_end(event), @@ -4522,213 +4164,37 @@ impl ChatWidget { fn on_collab_agent_tool_call(&mut self, item: ThreadItem) { let ThreadItem::CollabAgentToolCall { - id, - tool, - status, - sender_thread_id, - receiver_thread_ids, - prompt, - model, - reasoning_effort, - agents_states, - } = item + id, tool, status, .. + } = &item else { return; }; - let sender_thread_id = app_server_collab_thread_id_to_core(&sender_thread_id) - .or(self.thread_id) - .unwrap_or_default(); - let first_receiver = receiver_thread_ids - .first() - .and_then(|thread_id| app_server_collab_thread_id_to_core(thread_id)); - let first_receiver_metadata = - first_receiver.map(|thread_id| self.collab_agent_metadata(thread_id)); + if matches!(tool, CollabAgentTool::SpawnAgent) + && let Some(spawn_request) = multi_agents::spawn_request_summary(&item) + { + self.pending_collab_spawn_requests + .insert(id.clone(), spawn_request); + } - match tool { - CollabAgentTool::SpawnAgent => { - if let (Some(model), Some(reasoning_effort)) = (model.clone(), reasoning_effort) { - self.pending_collab_spawn_requests.insert( - id.clone(), - multi_agents::SpawnRequestSummary { - model, - reasoning_effort, - }, - ); - } + let cached_spawn_request = if matches!(tool, CollabAgentTool::SpawnAgent) + && !matches!(status, CollabAgentToolCallStatus::InProgress) + { + self.pending_collab_spawn_requests.remove(id) + } else { + None + }; - if !matches!(status, CollabAgentToolCallStatus::InProgress) { - let spawn_request = - self.pending_collab_spawn_requests.remove(&id).or_else(|| { - model - .zip(reasoning_effort) - .map(|(model, reasoning_effort)| { - multi_agents::SpawnRequestSummary { - model, - reasoning_effort, - } - }) - }); - self.on_collab_event(multi_agents::spawn_end( - codex_protocol::protocol::CollabAgentSpawnEndEvent { - call_id: id, - sender_thread_id, - new_thread_id: first_receiver, - new_agent_nickname: first_receiver_metadata - .as_ref() - .and_then(|metadata| metadata.agent_nickname.clone()), - new_agent_role: first_receiver_metadata - .as_ref() - .and_then(|metadata| metadata.agent_role.clone()), - prompt: prompt.unwrap_or_default(), - model: String::new(), - reasoning_effort: ReasoningEffortConfig::Medium, - status: first_receiver - .as_ref() - .and_then(|thread_id| agents_states.get(&thread_id.to_string())) - .map(app_server_collab_state_to_core) - .unwrap_or_else(|| { - AgentStatus::Errored("Agent spawn failed".into()) - }), - }, - spawn_request.as_ref(), - )); - } - } - CollabAgentTool::SendInput => { - if let Some(receiver_thread_id) = first_receiver - && !matches!(status, CollabAgentToolCallStatus::InProgress) - { - self.on_collab_event(multi_agents::interaction_end( - codex_protocol::protocol::CollabAgentInteractionEndEvent { - call_id: id, - sender_thread_id, - receiver_thread_id, - receiver_agent_nickname: first_receiver_metadata - .as_ref() - .and_then(|metadata| metadata.agent_nickname.clone()), - receiver_agent_role: first_receiver_metadata - .as_ref() - .and_then(|metadata| metadata.agent_role.clone()), - prompt: prompt.unwrap_or_default(), - status: receiver_thread_ids - .iter() - .find_map(|thread_id| agents_states.get(thread_id)) - .map(app_server_collab_state_to_core) - .unwrap_or_else(|| { - AgentStatus::Errored("Agent interaction failed".into()) - }), - }, - )); - } - } - CollabAgentTool::ResumeAgent => { - if let Some(receiver_thread_id) = first_receiver { - if matches!(status, CollabAgentToolCallStatus::InProgress) { - self.on_collab_event(multi_agents::resume_begin( - codex_protocol::protocol::CollabResumeBeginEvent { - call_id: id, - sender_thread_id, - receiver_thread_id, - receiver_agent_nickname: first_receiver_metadata - .as_ref() - .and_then(|metadata| metadata.agent_nickname.clone()), - receiver_agent_role: first_receiver_metadata - .as_ref() - .and_then(|metadata| metadata.agent_role.clone()), - }, - )); - } else { - self.on_collab_event(multi_agents::resume_end( - codex_protocol::protocol::CollabResumeEndEvent { - call_id: id, - sender_thread_id, - receiver_thread_id, - receiver_agent_nickname: first_receiver_metadata - .as_ref() - .and_then(|metadata| metadata.agent_nickname.clone()), - receiver_agent_role: first_receiver_metadata - .as_ref() - .and_then(|metadata| metadata.agent_role.clone()), - status: receiver_thread_ids - .iter() - .find_map(|thread_id| agents_states.get(thread_id)) - .map(app_server_collab_state_to_core) - .unwrap_or_else(|| { - AgentStatus::Errored("Agent resume failed".into()) - }), - }, - )); - } - } - } - CollabAgentTool::Wait => { - if matches!(status, CollabAgentToolCallStatus::InProgress) { - self.on_collab_event(multi_agents::waiting_begin( - codex_protocol::protocol::CollabWaitingBeginEvent { - sender_thread_id, - receiver_thread_ids: receiver_thread_ids - .iter() - .filter_map(|thread_id| { - app_server_collab_thread_id_to_core(thread_id) - }) - .collect(), - receiver_agents: app_server_collab_receiver_agent_refs( - &receiver_thread_ids, - &self.collab_agent_metadata, - ), - call_id: id, - }, - )); - } else { - let (agent_statuses, statuses) = app_server_collab_agent_statuses_to_core( - &receiver_thread_ids, - &agents_states, - &self.collab_agent_metadata, - ); - self.on_collab_event(multi_agents::waiting_end( - codex_protocol::protocol::CollabWaitingEndEvent { - sender_thread_id, - call_id: id, - agent_statuses, - statuses, - }, - )); - } - } - CollabAgentTool::CloseAgent => { - if let Some(receiver_thread_id) = first_receiver - && !matches!(status, CollabAgentToolCallStatus::InProgress) - { - self.on_collab_event(multi_agents::close_end( - codex_protocol::protocol::CollabCloseEndEvent { - call_id: id, - sender_thread_id, - receiver_thread_id, - receiver_agent_nickname: first_receiver_metadata - .as_ref() - .and_then(|metadata| metadata.agent_nickname.clone()), - receiver_agent_role: first_receiver_metadata - .as_ref() - .and_then(|metadata| metadata.agent_role.clone()), - status: receiver_thread_ids - .iter() - .find_map(|thread_id| agents_states.get(thread_id)) - .map(app_server_collab_state_to_core) - .unwrap_or_else(|| { - AgentStatus::Errored("Agent close failed".into()) - }), - }, - )); - } - } + if let Some(cell) = multi_agents::tool_call_history_cell( + &item, + cached_spawn_request.as_ref(), + |thread_id| self.collab_agent_metadata(thread_id), + ) { + self.on_collab_event(cell); } } - pub(crate) fn handle_history_entry_response( - &mut self, - event: codex_protocol::protocol::GetHistoryEntryResponseEvent, - ) { - let codex_protocol::protocol::GetHistoryEntryResponseEvent { + pub(crate) fn handle_history_entry_response(&mut self, event: HistoryLookupResponse) { + let HistoryLookupResponse { offset, log_id, entry, @@ -4754,8 +4220,7 @@ impl ChatWidget { "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_string() } - fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { - let DeprecationNoticeEvent { summary, details } = event; + fn on_deprecation_notice(&mut self, summary: String, details: Option) { self.add_to_history(history_cell::new_deprecation_notice(summary, details)); self.request_redraw(); } @@ -4770,7 +4235,7 @@ impl ChatWidget { self.set_status_header(message); } - fn on_hook_started(&mut self, event: codex_protocol::protocol::HookStartedEvent) { + fn on_hook_started(&mut self, event: HookStartedEvent) { self.flush_answer_stream_with_separator(); self.flush_completed_hook_output(); match self.active_hook_cell.as_mut() { @@ -4789,7 +4254,7 @@ impl ChatWidget { self.request_redraw(); } - fn on_hook_completed(&mut self, event: codex_protocol::protocol::HookCompletedEvent) { + fn on_hook_completed(&mut self, event: HookCompletedEvent) { let completed = event.run; let completed_existing_run = self .active_hook_cell @@ -5218,10 +4683,7 @@ impl ChatWidget { } } - pub(crate) fn handle_patch_apply_end_now( - &mut self, - event: codex_protocol::protocol::PatchApplyEndEvent, - ) { + pub(crate) fn handle_patch_apply_end_now(&mut self, event: PatchApplyEndEvent) { // If the patch was successful, just let the "Edited" block stand. // Otherwise, add a failure block. if !event.success { @@ -5237,7 +4699,11 @@ impl ChatWidget { .unwrap_or_else(|_| ev.command.join(" ")); self.notify(Notification::ExecApprovalRequested { command }); - let available_decisions = ev.effective_available_decisions(); + let available_decisions = ev + .effective_available_decisions() + .into_iter() + .map(Into::into) + .collect(); let request = ApprovalRequest::Exec { thread_id: self.thread_id.unwrap_or_default(), thread_label: None, @@ -5246,7 +4712,7 @@ impl ChatWidget { reason: ev.reason, available_decisions, network_approval_context: ev.network_approval_context, - additional_permissions: ev.additional_permissions, + additional_permissions: ev.additional_permissions.map(Into::into), }; self.bottom_pane .push_approval_request(request, &self.config.features); @@ -6195,9 +5661,7 @@ impl ChatWidget { ) -> QueueDrain { let drain = self.submit_shell_command(command); if drain == QueueDrain::Stop { - self.submit_op(Op::AddToHistory { - text: history_text.to_string(), - }); + self.submit_op(AppCommand::add_to_history(history_text.to_string())); } drain } @@ -6486,7 +5950,7 @@ impl ChatWidget { let op = AppCommand::user_turn( items, self.config.cwd.to_path_buf(), - self.config.permissions.approval_policy.value(), + AskForApproval::from(self.config.permissions.approval_policy.value()), permission_profile, effective_mode.model().to_string(), effective_mode.reasoning_effort(), @@ -6525,7 +5989,7 @@ impl ChatWidget { } }; if let Some(history_text) = history_text { - self.submit_op(Op::AddToHistory { text: history_text }); + self.submit_op(AppCommand::add_to_history(history_text)); } if let Some(pending_steer) = pending_steer { @@ -6771,7 +6235,7 @@ impl ChatWidget { .into_iter() .map(codex_app_server_protocol::CommandAction::into_core) .collect(), - source: source.to_core(), + source, interaction_input: None, }); } else { @@ -6786,7 +6250,7 @@ impl ChatWidget { .into_iter() .map(codex_app_server_protocol::CommandAction::into_core) .collect(), - source: source.to_core(), + source, interaction_input: None, stdout: String::new(), stderr: String::new(), @@ -6798,16 +6262,16 @@ impl ChatWidget { formatted_output: aggregated_output, status: match status { codex_app_server_protocol::CommandExecutionStatus::Completed => { - codex_protocol::protocol::ExecCommandStatus::Completed + codex_app_server_protocol::CommandExecutionStatus::Completed } codex_app_server_protocol::CommandExecutionStatus::Failed => { - codex_protocol::protocol::ExecCommandStatus::Failed + codex_app_server_protocol::CommandExecutionStatus::Failed } codex_app_server_protocol::CommandExecutionStatus::Declined => { - codex_protocol::protocol::ExecCommandStatus::Declined + codex_app_server_protocol::CommandExecutionStatus::Declined } codex_app_server_protocol::CommandExecutionStatus::InProgress => { - codex_protocol::protocol::ExecCommandStatus::Failed + codex_app_server_protocol::CommandExecutionStatus::Failed } }, }); @@ -6822,7 +6286,7 @@ impl ChatWidget { status, codex_app_server_protocol::PatchApplyStatus::InProgress ) { - self.on_patch_apply_end(codex_protocol::protocol::PatchApplyEndEvent { + self.on_patch_apply_end(PatchApplyEndEvent { call_id: id, turn_id: turn_id.clone(), stdout: String::new(), @@ -6834,16 +6298,16 @@ impl ChatWidget { changes: file_update_changes_to_core(changes), status: match status { codex_app_server_protocol::PatchApplyStatus::Completed => { - codex_protocol::protocol::PatchApplyStatus::Completed + codex_app_server_protocol::PatchApplyStatus::Completed } codex_app_server_protocol::PatchApplyStatus::Failed => { - codex_protocol::protocol::PatchApplyStatus::Failed + codex_app_server_protocol::PatchApplyStatus::Failed } codex_app_server_protocol::PatchApplyStatus::Declined => { - codex_protocol::protocol::PatchApplyStatus::Declined + codex_app_server_protocol::PatchApplyStatus::Declined } codex_app_server_protocol::PatchApplyStatus::InProgress => { - codex_protocol::protocol::PatchApplyStatus::Failed + codex_app_server_protocol::PatchApplyStatus::Failed } }, }); @@ -6860,9 +6324,9 @@ impl ChatWidget { duration_ms, .. } => { - self.on_mcp_tool_call_end(codex_protocol::protocol::McpToolCallEndEvent { + self.on_mcp_tool_call_end(McpToolCallEndEvent { call_id: id, - invocation: codex_protocol::protocol::McpInvocation { + invocation: McpInvocation { server, tool, arguments: Some(arguments), @@ -7030,12 +6494,9 @@ impl ChatWidget { } ServerNotification::ThreadNameUpdated(notification) => { match ThreadId::from_string(¬ification.thread_id) { - Ok(thread_id) => self.on_thread_name_updated( - codex_protocol::protocol::ThreadNameUpdatedEvent { - thread_id, - thread_name: notification.thread_name, - }, - ), + Ok(thread_id) => { + self.on_thread_name_updated(thread_id, notification.thread_name) + } Err(err) => { tracing::warn!( thread_id = notification.thread_id, @@ -7090,7 +6551,7 @@ impl ChatWidget { ServerNotification::CommandExecutionOutputDelta(notification) => { self.on_exec_command_output_delta(ExecCommandOutputDeltaEvent { call_id: notification.item_id, - stream: codex_protocol::protocol::ExecOutputStream::Stdout, + stream: codex_app_server_protocol::CommandExecOutputStream::Stdout, chunk: notification.delta.into_bytes(), }); } @@ -7154,10 +6615,7 @@ impl ChatWidget { self.on_warning(notification.message) } ServerNotification::DeprecationNotice(notification) => { - self.on_deprecation_notice(DeprecationNoticeEvent { - summary: notification.summary, - details: notification.details, - }) + self.on_deprecation_notice(notification.summary, notification.details) } ServerNotification::ConfigWarning(notification) => self.on_warning( notification @@ -7193,54 +6651,25 @@ impl ChatWidget { } ServerNotification::ThreadRealtimeStarted(notification) => { if !from_replay { - self.on_realtime_conversation_started( - codex_protocol::protocol::RealtimeConversationStartedEvent { - session_id: notification.session_id, - version: notification.version, - }, - ); + self.on_realtime_conversation_started(notification); } } - ServerNotification::ThreadRealtimeItemAdded(notification) => { - if !from_replay { - self.on_realtime_conversation_realtime( - codex_protocol::protocol::RealtimeConversationRealtimeEvent { - payload: codex_protocol::protocol::RealtimeEvent::ConversationItemAdded( - notification.item, - ), - }, - ); - } + ServerNotification::ThreadRealtimeItemAdded(_notification) => { + // The TUI currently renders transcript-specific realtime notifications instead. } ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => { if !from_replay { - self.on_realtime_conversation_realtime( - codex_protocol::protocol::RealtimeConversationRealtimeEvent { - payload: codex_protocol::protocol::RealtimeEvent::AudioOut( - notification.audio.into(), - ), - }, - ); + self.on_realtime_output_audio_delta(notification); } } ServerNotification::ThreadRealtimeError(notification) => { if !from_replay { - self.on_realtime_conversation_realtime( - codex_protocol::protocol::RealtimeConversationRealtimeEvent { - payload: codex_protocol::protocol::RealtimeEvent::Error( - notification.message, - ), - }, - ); + self.on_realtime_error(notification); } } ServerNotification::ThreadRealtimeClosed(notification) => { if !from_replay { - self.on_realtime_conversation_closed( - codex_protocol::protocol::RealtimeConversationClosedEvent { - reason: notification.reason, - }, - ); + self.on_realtime_conversation_closed(notification); } } ServerNotification::ThreadRealtimeSdp(notification) => { @@ -7275,7 +6704,7 @@ impl ChatWidget { } } - pub(crate) fn handle_skills_list_response(&mut self, response: ListSkillsResponseEvent) { + pub(crate) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { self.on_list_skills(response); } @@ -7386,7 +6815,7 @@ impl ChatWidget { .into_iter() .map(codex_app_server_protocol::CommandAction::into_core) .collect(), - source: source.to_core(), + source, interaction_input: None, }); } @@ -7408,7 +6837,7 @@ impl ChatWidget { } => { self.on_mcp_tool_call_begin(McpToolCallBeginEvent { call_id: id, - invocation: codex_protocol::protocol::McpInvocation { + invocation: McpInvocation { server, tool, arguments: Some(arguments), @@ -7497,31 +6926,31 @@ impl ChatWidget { }, risk_level: review.risk_level.map(|risk_level| match risk_level { codex_app_server_protocol::GuardianRiskLevel::Low => { - codex_protocol::protocol::GuardianRiskLevel::Low + codex_protocol::approvals::GuardianRiskLevel::Low } codex_app_server_protocol::GuardianRiskLevel::Medium => { - codex_protocol::protocol::GuardianRiskLevel::Medium + codex_protocol::approvals::GuardianRiskLevel::Medium } codex_app_server_protocol::GuardianRiskLevel::High => { - codex_protocol::protocol::GuardianRiskLevel::High + codex_protocol::approvals::GuardianRiskLevel::High } codex_app_server_protocol::GuardianRiskLevel::Critical => { - codex_protocol::protocol::GuardianRiskLevel::Critical + codex_protocol::approvals::GuardianRiskLevel::Critical } }), user_authorization: review.user_authorization.map(|user_authorization| { match user_authorization { codex_app_server_protocol::GuardianUserAuthorization::Unknown => { - codex_protocol::protocol::GuardianUserAuthorization::Unknown + codex_protocol::approvals::GuardianUserAuthorization::Unknown } codex_app_server_protocol::GuardianUserAuthorization::Low => { - codex_protocol::protocol::GuardianUserAuthorization::Low + codex_protocol::approvals::GuardianUserAuthorization::Low } codex_app_server_protocol::GuardianUserAuthorization::Medium => { - codex_protocol::protocol::GuardianUserAuthorization::Medium + codex_protocol::approvals::GuardianUserAuthorization::Medium } codex_app_server_protocol::GuardianUserAuthorization::High => { - codex_protocol::protocol::GuardianUserAuthorization::High + codex_protocol::approvals::GuardianUserAuthorization::High } } }), @@ -7568,7 +6997,7 @@ impl ChatWidget { self.dispatch_event_msg(/*id*/ None, msg, Some(ReplayKind::ThreadSnapshot)); } - /// Dispatch a protocol `EventMsg` to the appropriate handler. + /// Dispatch a test-only event message to the appropriate handler. /// /// `id` is `Some` for live events and `None` for replayed events from /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id @@ -7593,7 +7022,6 @@ impl ChatWidget { | EventMsg::PlanDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::TerminalInteraction(_) - | EventMsg::PatchApplyUpdated(_) | EventMsg::ExecCommandOutputDelta(_) => {} _ => { tracing::trace!("handle_codex_event: {:?}", msg); @@ -7602,29 +7030,8 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), - EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), - EventMsg::ThreadGoalUpdated(event) => { - let goal = event.goal; - self.on_thread_goal_updated( - AppThreadGoal { - thread_id: goal.thread_id.to_string(), - objective: goal.objective, - status: match goal.status { - ProtocolThreadGoalStatus::Active => AppThreadGoalStatus::Active, - ProtocolThreadGoalStatus::Paused => AppThreadGoalStatus::Paused, - ProtocolThreadGoalStatus::BudgetLimited => { - AppThreadGoalStatus::BudgetLimited - } - ProtocolThreadGoalStatus::Complete => AppThreadGoalStatus::Complete, - }, - token_budget: goal.token_budget, - tokens_used: goal.tokens_used, - time_used_seconds: goal.time_used_seconds, - created_at: goal.created_at, - updated_at: goal.updated_at, - }, - event.turn_id, - ); + EventMsg::ThreadNameUpdated(e) => { + self.on_thread_name_updated(e.thread_id, e.thread_name) } // NOTE: All three AgentMessage arms feed `record_agent_markdown` even // when the message is otherwise not rendered (thread-snapshot replay, @@ -7667,7 +7074,6 @@ impl ChatWidget { self.on_agent_reasoning_delta(text); self.on_agent_reasoning_final(); } - EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TurnStarted(event) => { let turn_id = event.turn_id; let model_context_window = event.model_context_window; @@ -7691,7 +7097,6 @@ impl ChatWidget { EventMsg::Warning(WarningEvent { message }) | EventMsg::GuardianWarning(WarningEvent { message }) => self.on_warning(message), EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev), - EventMsg::ModelReroute(_) => {} EventMsg::ModelVerification(event) => { self.on_core_model_verification(&event.verifications) } @@ -7785,7 +7190,6 @@ impl ChatWidget { EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev), EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev), - EventMsg::GetHistoryEntryResponse(ev) => self.handle_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), EventMsg::SkillsUpdateAvailable => { @@ -7793,7 +7197,7 @@ impl ChatWidget { } EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), - EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), + EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev.summary, ev.details), EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { self.on_background_event(message) } @@ -7813,41 +7217,10 @@ impl ChatWidget { self.on_user_message_event(ev); } } - EventMsg::EnteredReviewMode(review_request) => { - self.on_entered_review_mode(review_request, from_replay) + EventMsg::EnteredReviewMode(hint) => { + self.enter_review_mode_with_hint(hint, from_replay) } EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), - EventMsg::ContextCompacted(_) => {} - EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { - call_id, - model, - reasoning_effort, - .. - }) => { - self.pending_collab_spawn_requests.insert( - call_id, - multi_agents::SpawnRequestSummary { - model, - reasoning_effort, - }, - ); - } - EventMsg::CollabAgentSpawnEnd(ev) => { - let spawn_request = self.pending_collab_spawn_requests.remove(&ev.call_id); - self.on_collab_event(multi_agents::spawn_end(ev, spawn_request.as_ref())); - } - EventMsg::CollabAgentInteractionBegin(_) => {} - EventMsg::CollabAgentInteractionEnd(ev) => { - self.on_collab_event(multi_agents::interaction_end(ev)) - } - EventMsg::CollabWaitingBegin(ev) => { - self.on_collab_event(multi_agents::waiting_begin(ev)) - } - EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(multi_agents::waiting_end(ev)), - EventMsg::CollabCloseBegin(_) => {} - EventMsg::CollabCloseEnd(ev) => self.on_collab_event(multi_agents::close_end(ev)), - EventMsg::CollabResumeBegin(ev) => self.on_collab_event(multi_agents::resume_begin(ev)), - EventMsg::CollabResumeEnd(ev) => self.on_collab_event(multi_agents::resume_end(ev)), EventMsg::ThreadRolledBack(rollback) => { if from_replay { self.app_event_tx.send(AppEvent::ApplyThreadRollback { @@ -7855,37 +7228,8 @@ impl ChatWidget { }); } } - EventMsg::RawResponseItem(_) - | EventMsg::ItemStarted(_) - | EventMsg::AgentMessageContentDelta(_) - | EventMsg::PatchApplyUpdated(_) - | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::DynamicToolCallRequest(_) - | EventMsg::DynamicToolCallResponse(_) - | EventMsg::RealtimeConversationListVoicesResponse(_) => {} EventMsg::HookStarted(event) => self.on_hook_started(event), EventMsg::HookCompleted(event) => self.on_hook_completed(event), - EventMsg::RealtimeConversationStarted(ev) => { - if !from_replay { - self.on_realtime_conversation_started(ev); - } - } - EventMsg::RealtimeConversationSdp(ev) => { - if !from_replay { - self.on_realtime_conversation_sdp(ev.sdp); - } - } - EventMsg::RealtimeConversationRealtime(ev) => { - if !from_replay { - self.on_realtime_conversation_realtime(ev); - } - } - EventMsg::RealtimeConversationClosed(ev) => { - if !from_replay { - self.on_realtime_conversation_closed(ev); - } - } EventMsg::ItemCompleted(event) => { let item = event.item; if !from_replay && let codex_protocol::items::TurnItem::UserMessage(item) = &item { @@ -7931,52 +7275,18 @@ impl ChatWidget { } #[cfg(test)] - fn on_entered_review_mode(&mut self, review: ReviewRequest, from_replay: bool) { - let hint = review.user_facing_hint.unwrap_or_else(|| { - crate::legacy_core::review_prompts::user_facing_hint(&review.target) - }); - self.enter_review_mode_with_hint(hint, from_replay); - } - - #[cfg(test)] - fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) { - if let Some(output) = review.review_output { - let review_markdown = - crate::legacy_core::review_format::render_review_output_text(&output); - self.record_agent_markdown(&review_markdown); - self.flush_answer_stream_with_separator(); - self.flush_interrupt_queue(); - self.flush_active_cell(); - - if output.findings.is_empty() { - let explanation = output.overall_explanation.trim().to_string(); - if explanation.is_empty() { - tracing::error!("Reviewer failed to output a response."); - self.add_to_history(history_cell::new_error_event( - "Reviewer failed to output a response.".to_owned(), - )); - } else { - // Show explanation when there are no structured findings. - let mut rendered: Vec> = vec!["".into()]; - crate::markdown::append_markdown( - &explanation, - /*width*/ None, - Some(self.config.cwd.as_path()), - &mut rendered, - ); - let body_cell = AgentMessageCell::new(rendered, /*is_first_line*/ false); - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); - } - } - // Final message is rendered as part of the AgentMessage. - } + fn on_exited_review_mode(&mut self, _review: ExitedReviewModeEvent) { self.exit_review_mode_after_item(); } fn on_committed_user_message(&mut self, item: &UserMessageItem, from_replay: bool) { - let EventMsg::UserMessage(event) = item.as_legacy_event() else { - unreachable!("user message item should convert to a legacy user message"); + let rendered_event = Self::rendered_user_message_event_from_inputs(&item.content); + let event = UserMessageEvent { + message: rendered_event.message, + images: (!rendered_event.remote_image_urls.is_empty()) + .then_some(rendered_event.remote_image_urls), + local_images: rendered_event.local_images, + text_elements: rendered_event.text_elements, }; if from_replay { if !self.is_review_mode { @@ -8562,22 +7872,19 @@ impl ChatWidget { let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; let switch_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp( - AppCommand::override_turn_context( - /*cwd*/ None, - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - /*permission_profile*/ None, - /*windows_sandbox_level*/ None, - Some(switch_model_for_events.clone()), - Some(Some(default_effort)), - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - /*personality*/ None, - ) - .into_core(), - )); + tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*permission_profile*/ None, + /*windows_sandbox_level*/ None, + Some(switch_model_for_events.clone()), + Some(Some(default_effort)), + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ))); tx.send(AppEvent::UpdateModel(switch_model_for_events.clone())); tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); })]; @@ -8786,22 +8093,19 @@ impl ChatWidget { let name = Self::personality_label(personality).to_string(); let description = Some(Self::personality_description(personality).to_string()); let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp( - AppCommand::override_turn_context( - /*cwd*/ None, - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - /*permission_profile*/ None, - /*windows_sandbox_level*/ None, - /*model*/ None, - /*effort*/ None, - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - Some(personality), - ) - .into_core(), - )); + tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*permission_profile*/ None, + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + Some(personality), + ))); tx.send(AppEvent::UpdatePersonality(personality)); tx.send(AppEvent::PersistPersonalitySelection { personality }); })]; @@ -9543,7 +8847,8 @@ impl ChatWidget { /// Open a popup to choose the permissions mode. pub(crate) fn open_permissions_popup(&mut self) { let include_read_only = cfg!(target_os = "windows"); - let current_approval = self.config.permissions.approval_policy.value(); + let current_approval = + AskForApproval::from(self.config.permissions.approval_policy.value()); let current_permission_profile = self.config.permissions.permission_profile(); let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); let current_review_policy = self.config.approvals_reviewer; @@ -9582,6 +8887,7 @@ impl ChatWidget { } else { preset.label.to_string() }; + let preset_approval = AskForApproval::from(preset.approval); let base_description = Some(preset.description.replace(" (Identical to Agent mode)", "")); let approval_disabled_reason = match self @@ -9649,7 +8955,7 @@ impl ChatWidget { })] } else { Self::approval_preset_actions( - preset.approval, + preset_approval, preset.permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, @@ -9659,7 +8965,7 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] { Self::approval_preset_actions( - preset.approval, + preset_approval, preset.permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, @@ -9667,7 +8973,7 @@ impl ChatWidget { } } else { Self::approval_preset_actions( - preset.approval, + preset_approval, preset.permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, @@ -9699,13 +9005,13 @@ impl ChatWidget { ), is_current: current_review_policy == ApprovalsReviewer::AutoReview && Self::preset_matches_current( - current_approval, - ¤t_permission_profile, - self.config.cwd.as_path(), - &preset, - ), + current_approval, + ¤t_permission_profile, + self.config.cwd.as_path(), + &preset, + ), actions: Self::approval_preset_actions( - preset.approval, + preset_approval, preset.permission_profile.clone(), "Auto-review".to_string(), ApprovalsReviewer::AutoReview, @@ -9816,7 +9122,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::SubmitThreadOp { thread_id, - op: Op::ApproveGuardianDeniedAction { event }, + op: AppCommand::approve_guardian_denied_action(event), }); self.add_info_message( "Approval recorded for one retry of the selected auto-review denial.".to_string(), @@ -9854,22 +9160,19 @@ impl ChatWidget { ) -> Vec { vec![Box::new(move |tx| { let permission_profile_clone = permission_profile.clone(); - tx.send(AppEvent::CodexOp( - AppCommand::override_turn_context( - /*cwd*/ None, - Some(approval), - Some(approvals_reviewer), - Some(permission_profile_clone.clone()), - /*windows_sandbox_level*/ None, - /*model*/ None, - /*effort*/ None, - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - /*personality*/ None, - ) - .into_core(), - )); + tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + Some(approval), + Some(approvals_reviewer), + Some(permission_profile_clone.clone()), + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ))); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdatePermissionProfile(permission_profile_clone)); tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); @@ -9888,7 +9191,8 @@ impl ChatWidget { cwd: &std::path::Path, preset: &ApprovalPreset, ) -> bool { - if current_approval != preset.approval { + let preset_approval = AskForApproval::from(preset.approval); + if current_approval != preset_approval { return false; } @@ -9964,7 +9268,7 @@ impl ChatWidget { return_to_permissions: bool, ) { let selected_name = preset.label.to_string(); - let approval = preset.approval; + let approval = AskForApproval::from(preset.approval); let permission_profile = preset.permission_profile; let mut header_children: Vec> = Vec::new(); let title_line = Line::from("Enable full access?").bold(); @@ -10050,7 +9354,10 @@ impl ChatWidget { failed_scan: bool, ) { let (approval, permission_profile) = match &preset { - Some(p) => (Some(p.approval), Some(p.permission_profile.clone())), + Some(p) => ( + Some(AskForApproval::from(p.approval)), + Some(p.permission_profile.clone()), + ), None => (None, None), }; let mut header_children: Vec> = Vec::new(); @@ -10443,7 +9750,12 @@ impl ChatWidget { /// Set the approval policy in the widget's config copy. pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { - if let Err(err) = self.config.permissions.approval_policy.set(policy) { + if let Err(err) = self + .config + .permissions + .approval_policy + .set(policy.to_core()) + { tracing::warn!(%err, "failed to set approval_policy on chat config"); } } @@ -10706,8 +10018,8 @@ impl ChatWidget { self.config.notices.fast_default_opt_out = Some(true); } self.set_service_tier(service_tier); - self.app_event_tx.send(AppEvent::CodexOp( - AppCommand::override_turn_context( + self.app_event_tx + .send(AppEvent::CodexOp(AppCommand::override_turn_context( /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, @@ -10719,9 +10031,7 @@ impl ChatWidget { Some(service_tier), /*collaboration_mode*/ None, /*personality*/ None, - ) - .into_core(), - )); + ))); self.app_event_tx .send(AppEvent::PersistServiceTierSelection { service_tier }); } @@ -11782,22 +11092,20 @@ impl ChatWidget { match &self.codex_op_target { CodexOpTarget::Direct(codex_op_tx) => { crate::session_log::log_outbound_op(&op); - if let Err(e) = codex_op_tx.send(op.into_core()) { + if let Err(e) = codex_op_tx.send(op) { tracing::error!("failed to submit op: {e}"); return false; } } CodexOpTarget::AppEvent => { - self.app_event_tx.send(AppEvent::CodexOp(op.into())); + self.app_event_tx.send(AppEvent::CodexOp(op)); } } true } pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) { - if matches!(op.view(), crate::app_command::AppCommandView::Interrupt) - && self.agent_turn_running - { + if matches!(op, AppCommand::Interrupt) && self.agent_turn_running { if let Some(controller) = self.stream_controller.as_mut() { controller.clear_queue(); } @@ -11819,7 +11127,7 @@ impl ChatWidget { )); } - fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { + fn on_list_skills(&mut self, ev: SkillsListResponse) { self.set_skills_from_response(&ev); self.refresh_plugin_mentions(); } @@ -11962,10 +11270,7 @@ impl ChatWidget { items.push(SelectionItem { name: "Review uncommitted changes".to_string(), actions: vec![Box::new(move |tx: &AppEventSender| { - tx.review(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }); + tx.review(ReviewTarget::UncommittedChanges); })], dismiss_on_select: true, ..Default::default() @@ -12015,11 +11320,8 @@ impl ChatWidget { items.push(SelectionItem { name: format!("{current_branch} -> {branch}"), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.review(ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: branch.clone(), - }, - user_facing_hint: None, + tx3.review(ReviewTarget::BaseBranch { + branch: branch.clone(), }); })], dismiss_on_select: true, @@ -12050,12 +11352,9 @@ impl ChatWidget { items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.review(ReviewRequest { - target: ReviewTarget::Commit { - sha: sha.clone(), - title: Some(subject.clone()), - }, - user_facing_hint: None, + tx3.review(ReviewTarget::Commit { + sha: sha.clone(), + title: Some(subject.clone()), }); })], dismiss_on_select: true, @@ -12086,11 +11385,8 @@ impl ChatWidget { if trimmed.is_empty() { return; } - tx.review(ReviewRequest { - target: ReviewTarget::Custom { - instructions: trimmed, - }, - user_facing_hint: None, + tx.review(ReviewTarget::Custom { + instructions: trimmed, }); }), ); @@ -12428,12 +11724,9 @@ pub(crate) fn show_review_commit_picker_with_entries( items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.review(ReviewRequest { - target: ReviewTarget::Commit { - sha: sha.clone(), - title: Some(subject.clone()), - }, - user_facing_hint: None, + tx3.review(ReviewTarget::Commit { + sha: sha.clone(), + title: Some(subject.clone()), }); })], dismiss_on_select: true, @@ -12452,5 +11745,8 @@ pub(crate) fn show_review_commit_picker_with_entries( }); } +#[cfg(test)] +pub(crate) mod test_events; + #[cfg(test)] pub(crate) mod tests; diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index f41e3b3a47..f5664bb41a 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -1,14 +1,14 @@ use std::collections::VecDeque; use crate::app::app_server_requests::ResolvedAppServerRequest; +use crate::approval_events::ApplyPatchApprovalRequestEvent; +use crate::approval_events::ExecApprovalRequestEvent; +use crate::tool_activity::ExecCommandBeginEvent; +use crate::tool_activity::ExecCommandEndEvent; +use crate::tool_activity::McpToolCallBeginEvent; +use crate::tool_activity::McpToolCallEndEvent; +use crate::tool_activity::PatchApplyEndEvent; use codex_protocol::approvals::ElicitationRequestEvent; -use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; -use codex_protocol::protocol::ExecApprovalRequestEvent; -use codex_protocol::protocol::ExecCommandBeginEvent; -use codex_protocol::protocol::ExecCommandEndEvent; -use codex_protocol::protocol::McpToolCallBeginEvent; -use codex_protocol::protocol::McpToolCallEndEvent; -use codex_protocol::protocol::PatchApplyEndEvent; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; @@ -148,9 +148,9 @@ impl QueuedInterrupt { #[cfg(test)] mod tests { - use codex_protocol::approvals::ExecApprovalRequestEvent; - use codex_protocol::protocol::ExecCommandBeginEvent; - use codex_protocol::protocol::ExecCommandSource; + use crate::approval_events::ExecApprovalRequestEvent; + use crate::tool_activity::ExecCommandBeginEvent; + use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; diff --git a/codex-rs/tui/src/chatwidget/mcp_startup.rs b/codex-rs/tui/src/chatwidget/mcp_startup.rs new file mode 100644 index 0000000000..e5ed9f2939 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/mcp_startup.rs @@ -0,0 +1,275 @@ +//! MCP startup state and status handling for the chat widget. +//! +//! The app server reports MCP server startup as per-server status updates. This +//! module keeps the TUI's buffered startup round state coherent and translates +//! those updates into status headers, warnings, and queued-input release points. + +use std::collections::BTreeSet; + +use codex_app_server_protocol::McpServerStartupState; +use codex_app_server_protocol::McpServerStatusUpdatedNotification; +use serde::Deserialize; +use serde::Serialize; + +use super::ChatWidget; +#[cfg(test)] +use super::test_events::McpStartupCompleteEvent; +#[cfg(test)] +use super::test_events::McpStartupUpdateEvent; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "state")] +pub(crate) enum McpStartupStatus { + Starting, + Ready, + Failed { error: String }, + Cancelled, +} + +impl ChatWidget { + /// Record one MCP startup update, promoting it into either the active startup + /// round or a buffered "next" round. + /// + /// This path has to deal with lossy app-server delivery. After + /// `finish_mcp_startup()` or `finish_mcp_startup_after_lag()`, we briefly + /// ignore incoming updates so stale events from the just-finished round do not + /// reopen startup. While that guard is active we buffer updates for a possible + /// next round, and only reactivate once the buffered set is coherent enough to + /// treat as a fresh startup round. + fn update_mcp_startup_status( + &mut self, + server: String, + status: McpStartupStatus, + complete_when_settled: bool, + ) { + let mut activated_pending_round = false; + let startup_status = if self.mcp_startup_ignore_updates_until_next_start { + // Ignore-mode buffers the next plausible round so stale post-finish + // updates cannot immediately reopen startup. A fresh `Starting` + // update resets the buffer only if we have not already seen a + // pending-round `Starting`; this preserves valid interleavings like + // `alpha: Starting -> alpha: Ready -> beta: Starting`. + if matches!(status, McpStartupStatus::Starting) + && !self.mcp_startup_pending_next_round_saw_starting + { + self.mcp_startup_pending_next_round.clear(); + self.mcp_startup_allow_terminal_only_next_round = false; + } + self.mcp_startup_pending_next_round_saw_starting |= + matches!(status, McpStartupStatus::Starting); + self.mcp_startup_pending_next_round.insert(server, status); + let Some(expected_servers) = &self.mcp_startup_expected_servers else { + return; + }; + let saw_full_round = expected_servers.is_empty() + || expected_servers + .iter() + .all(|name| self.mcp_startup_pending_next_round.contains_key(name)); + let saw_starting = self + .mcp_startup_pending_next_round + .values() + .any(|state| matches!(state, McpStartupStatus::Starting)); + if !(saw_full_round + && (saw_starting || self.mcp_startup_allow_terminal_only_next_round)) + { + return; + } + + // The buffered map now looks like a complete next round, so promote it + // to the active round and resume normal completion tracking. + self.mcp_startup_ignore_updates_until_next_start = false; + self.mcp_startup_allow_terminal_only_next_round = false; + self.mcp_startup_pending_next_round_saw_starting = false; + activated_pending_round = true; + std::mem::take(&mut self.mcp_startup_pending_next_round) + } else { + // Normal path: fold the update into the active round and surface + // per-server failures immediately. + let mut startup_status = self.mcp_startup_status.take().unwrap_or_default(); + if let McpStartupStatus::Failed { error } = &status { + self.on_warning(error); + } + startup_status.insert(server, status); + startup_status + }; + if activated_pending_round { + // A promoted buffered round may already contain terminal failures. + for state in startup_status.values() { + if let McpStartupStatus::Failed { error } = state { + self.on_warning(error); + } + } + } + self.mcp_startup_status = Some(startup_status); + self.update_task_running_state(); + + // App-server-backed startup completes when every expected server has + // reported a non-Starting status. Lag handling can force an earlier + // settle via `finish_mcp_startup_after_lag()`. + if complete_when_settled + && let Some(current) = &self.mcp_startup_status + && let Some(expected_servers) = &self.mcp_startup_expected_servers + && !current.is_empty() + && expected_servers + .iter() + .all(|name| current.contains_key(name)) + && current + .values() + .all(|state| !matches!(state, McpStartupStatus::Starting)) + { + let mut failed = Vec::new(); + let mut cancelled = Vec::new(); + for (name, state) in current { + match state { + McpStartupStatus::Ready => {} + McpStartupStatus::Failed { .. } => failed.push(name.clone()), + McpStartupStatus::Cancelled => cancelled.push(name.clone()), + McpStartupStatus::Starting => {} + } + } + failed.sort(); + cancelled.sort(); + self.finish_mcp_startup(failed, cancelled); + return; + } + if let Some(current) = &self.mcp_startup_status { + // Otherwise keep the status header focused on the remaining + // in-progress servers for the active round. + let total = current.len(); + let mut starting: Vec<_> = current + .iter() + .filter_map(|(name, state)| { + if matches!(state, McpStartupStatus::Starting) { + Some(name) + } else { + None + } + }) + .collect(); + starting.sort(); + if let Some(first) = starting.first() { + let completed = total.saturating_sub(starting.len()); + let max_to_show = 3; + let mut to_show: Vec = starting + .iter() + .take(max_to_show) + .map(ToString::to_string) + .collect(); + if starting.len() > max_to_show { + to_show.push("…".to_string()); + } + let header = if total > 1 { + format!( + "Starting MCP servers ({completed}/{total}): {}", + to_show.join(", ") + ) + } else { + format!("Booting MCP server: {first}") + }; + self.set_status_header(header); + } + } + self.request_redraw(); + } + + pub(crate) fn set_mcp_startup_expected_servers(&mut self, server_names: I) + where + I: IntoIterator, + { + self.mcp_startup_expected_servers = Some(server_names.into_iter().collect()); + } + + #[cfg(test)] + pub(super) fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { + self.update_mcp_startup_status(ev.server, ev.status, /*complete_when_settled*/ false); + } + + pub(super) fn finish_mcp_startup(&mut self, failed: Vec, cancelled: Vec) { + if !cancelled.is_empty() { + self.on_warning(format!( + "MCP startup interrupted. The following servers were not initialized: {}", + cancelled.join(", ") + )); + } + let mut parts = Vec::new(); + if !failed.is_empty() { + parts.push(format!("failed: {}", failed.join(", "))); + } + if !parts.is_empty() { + self.on_warning(format!("MCP startup incomplete ({})", parts.join("; "))); + } + + self.mcp_startup_status = None; + self.mcp_startup_ignore_updates_until_next_start = true; + self.mcp_startup_allow_terminal_only_next_round = false; + self.mcp_startup_pending_next_round.clear(); + self.mcp_startup_pending_next_round_saw_starting = false; + self.update_task_running_state(); + self.maybe_send_next_queued_input(); + self.request_redraw(); + } + + pub(crate) fn finish_mcp_startup_after_lag(&mut self) { + if self.mcp_startup_ignore_updates_until_next_start { + if self.mcp_startup_pending_next_round.is_empty() { + self.mcp_startup_pending_next_round_saw_starting = false; + } + self.mcp_startup_allow_terminal_only_next_round = true; + } + + let Some(current) = &self.mcp_startup_status else { + return; + }; + + let mut failed = Vec::new(); + let mut cancelled = Vec::new(); + + let mut server_names: BTreeSet = current.keys().cloned().collect(); + if let Some(expected_servers) = &self.mcp_startup_expected_servers { + server_names.extend(expected_servers.iter().cloned()); + } + + for name in server_names { + match current.get(&name) { + Some(McpStartupStatus::Ready) => {} + Some(McpStartupStatus::Failed { .. }) => failed.push(name), + Some(McpStartupStatus::Cancelled | McpStartupStatus::Starting) | None => { + cancelled.push(name); + } + } + } + + failed.sort(); + failed.dedup(); + cancelled.sort(); + cancelled.dedup(); + self.finish_mcp_startup(failed, cancelled); + } + + #[cfg(test)] + pub(super) fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { + let failed = ev.failed.into_iter().map(|f| f.server).collect(); + self.finish_mcp_startup(failed, ev.cancelled); + } + + pub(super) fn on_mcp_server_status_updated( + &mut self, + notification: McpServerStatusUpdatedNotification, + ) { + let status = match notification.status { + McpServerStartupState::Starting => McpStartupStatus::Starting, + McpServerStartupState::Ready => McpStartupStatus::Ready, + McpServerStartupState::Failed => McpStartupStatus::Failed { + error: notification.error.unwrap_or_else(|| { + format!("MCP client for `{}` failed to start", notification.name) + }), + }, + McpServerStartupState::Cancelled => McpStartupStatus::Cancelled, + }; + self.update_mcp_startup_status( + notification.name, + status, + /*complete_when_settled*/ true, + ); + } +} diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index bfeaff2eae..00097a476c 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -1,13 +1,11 @@ use super::*; +use codex_app_server_protocol::ThreadRealtimeAudioChunk; +use codex_app_server_protocol::ThreadRealtimeClosedNotification; +use codex_app_server_protocol::ThreadRealtimeErrorNotification; +use codex_app_server_protocol::ThreadRealtimeOutputAudioDeltaNotification; +use codex_app_server_protocol::ThreadRealtimeStartTransport; +use codex_app_server_protocol::ThreadRealtimeStartedNotification; use codex_config::config_toml::RealtimeTransport; -use codex_protocol::protocol::ConversationStartParams; -use codex_protocol::protocol::ConversationStartTransport; -use codex_protocol::protocol::RealtimeAudioFrame; -use codex_protocol::protocol::RealtimeConversationClosedEvent; -use codex_protocol::protocol::RealtimeConversationRealtimeEvent; -use codex_protocol::protocol::RealtimeConversationStartedEvent; -use codex_protocol::protocol::RealtimeEvent; -use codex_protocol::protocol::RealtimeOutputModality; use codex_realtime_webrtc::RealtimeWebrtcEvent; use codex_realtime_webrtc::RealtimeWebrtcSession; use codex_realtime_webrtc::RealtimeWebrtcSessionHandle; @@ -66,118 +64,7 @@ impl RealtimeConversationUiState { } } -#[derive(Clone, Debug, PartialEq)] -pub(super) struct RenderedUserMessageEvent { - pub(super) message: String, - pub(super) remote_image_urls: Vec, - pub(super) local_images: Vec, - pub(super) text_elements: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct PendingSteerCompareKey { - pub(super) message: String, - pub(super) image_count: usize, -} - impl ChatWidget { - pub(super) fn rendered_user_message_event_from_parts( - message: String, - text_elements: Vec, - local_images: Vec, - remote_image_urls: Vec, - ) -> RenderedUserMessageEvent { - RenderedUserMessageEvent { - message, - remote_image_urls, - local_images, - text_elements, - } - } - - pub(super) fn rendered_user_message_event_from_event( - event: &UserMessageEvent, - ) -> RenderedUserMessageEvent { - Self::rendered_user_message_event_from_parts( - event.message.clone(), - event.text_elements.clone(), - event.local_images.clone(), - event.images.clone().unwrap_or_default(), - ) - } - - /// Build the compare key for a submitted pending steer without invoking the - /// expensive request-serialization path. Pending steers only need to match the - /// committed `ItemCompleted(UserMessage)` emitted after core drains input, which - /// preserves flattened text and total image count but not UI-only text ranges or - /// local image paths. - pub(super) fn pending_steer_compare_key_from_items( - items: &[UserInput], - ) -> PendingSteerCompareKey { - let mut message = String::new(); - let mut image_count = 0; - - for item in items { - match item { - UserInput::Text { text, .. } => message.push_str(text), - UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1, - UserInput::Skill { .. } | UserInput::Mention { .. } => {} - _ => {} - } - } - - PendingSteerCompareKey { - message, - image_count, - } - } - - pub(super) fn pending_steer_compare_key_from_item( - item: &codex_protocol::items::UserMessageItem, - ) -> PendingSteerCompareKey { - Self::pending_steer_compare_key_from_items(&item.content) - } - - #[cfg(test)] - pub(super) fn rendered_user_message_event_from_inputs( - items: &[UserInput], - ) -> RenderedUserMessageEvent { - let mut message = String::new(); - let mut remote_image_urls = Vec::new(); - let mut local_images = Vec::new(); - let mut text_elements = Vec::new(); - - for item in items { - match item { - UserInput::Text { - text, - text_elements: current_text_elements, - } => append_text_with_rebased_elements( - &mut message, - &mut text_elements, - text, - current_text_elements.iter().map(|element| { - TextElement::new( - element.byte_range, - element.placeholder(text).map(str::to_string), - ) - }), - ), - UserInput::Image { image_url } => remote_image_urls.push(image_url.clone()), - UserInput::LocalImage { path } => local_images.push(path.clone()), - UserInput::Skill { .. } | UserInput::Mention { .. } => {} - _ => {} - } - } - - Self::rendered_user_message_event_from_parts( - message, - text_elements, - local_images, - remote_image_urls, - ) - } - #[cfg(test)] pub(super) fn should_render_realtime_user_message_event( &self, @@ -232,16 +119,14 @@ impl ChatWidget { fn submit_realtime_conversation_start( &mut self, - transport: Option, + transport: Option, ) { self.submit_op(AppCommand::realtime_conversation_start( - ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: None, - session_id: None, - transport, - voice: self.config.realtime.voice, - }, + transport, + self.config + .realtime + .voice + .and_then(|voice| serde_json::to_value(voice).ok()), )); } @@ -289,13 +174,13 @@ impl ChatWidget { pub(super) fn on_realtime_conversation_started( &mut self, - ev: RealtimeConversationStartedEvent, + notification: ThreadRealtimeStartedNotification, ) { if !self.realtime_conversation_enabled() { self.request_realtime_conversation_close(/*info_message*/ None); return; } - self.realtime_conversation.session_id = ev.session_id; + self.realtime_conversation.session_id = notification.session_id; self.set_footer_hint_override(Some(Self::realtime_footer_hint_items())); if self.realtime_conversation_uses_webrtc() { self.realtime_conversation.phase = RealtimeConversationPhase::Starting; @@ -306,55 +191,33 @@ impl ChatWidget { self.request_redraw(); } - pub(super) fn on_realtime_conversation_realtime( + pub(super) fn on_realtime_output_audio_delta( &mut self, - ev: RealtimeConversationRealtimeEvent, + notification: ThreadRealtimeOutputAudioDeltaNotification, ) { - if self.realtime_conversation_uses_webrtc() - && matches!( - ev.payload, - RealtimeEvent::AudioOut(_) - | RealtimeEvent::InputAudioSpeechStarted(_) - | RealtimeEvent::ResponseCreated(_) - | RealtimeEvent::ResponseCancelled(_) - | RealtimeEvent::ResponseDone(_) - ) - { + if self.realtime_conversation_uses_webrtc() { return; } - match ev.payload { - RealtimeEvent::SessionUpdated { session_id, .. } => { - self.realtime_conversation.session_id = Some(session_id); - } - RealtimeEvent::InputAudioSpeechStarted(_) => self.interrupt_realtime_audio_playback(), - RealtimeEvent::InputTranscriptDelta(_) => {} - RealtimeEvent::InputTranscriptDone(_) => {} - RealtimeEvent::OutputTranscriptDelta(_) => {} - RealtimeEvent::OutputTranscriptDone(_) => {} - RealtimeEvent::AudioOut(frame) => self.enqueue_realtime_audio_out(&frame), - RealtimeEvent::ResponseCreated(_) => {} - RealtimeEvent::ResponseCancelled(_) => self.interrupt_realtime_audio_playback(), - RealtimeEvent::ResponseDone(_) => {} - RealtimeEvent::ConversationItemAdded(_item) => {} - RealtimeEvent::ConversationItemDone { .. } => {} - RealtimeEvent::HandoffRequested(_) => {} - RealtimeEvent::NoopRequested(_) => {} - RealtimeEvent::Error(message) => { - self.fail_realtime_conversation(format!("Realtime voice error: {message}")); - } - } + self.enqueue_realtime_audio_out(¬ification.audio); } - pub(super) fn on_realtime_conversation_closed(&mut self, ev: RealtimeConversationClosedEvent) { + pub(super) fn on_realtime_error(&mut self, notification: ThreadRealtimeErrorNotification) { + self.fail_realtime_conversation(format!("Realtime voice error: {}", notification.message)); + } + + pub(super) fn on_realtime_conversation_closed( + &mut self, + notification: ThreadRealtimeClosedNotification, + ) { if self.realtime_conversation_uses_webrtc() && self.realtime_conversation.is_live() - && ev.reason.as_deref() == Some("transport_closed") + && notification.reason.as_deref() == Some("transport_closed") { return; } let requested = self.realtime_conversation.requested_close; - let reason = ev.reason; + let reason = notification.reason; self.reset_realtime_conversation_state(); if !requested && let Some(reason) = reason @@ -405,7 +268,7 @@ impl ChatWidget { self.realtime_conversation.transport = RealtimeConversationUiTransport::Webrtc { handle: Some(offer.handle), }; - self.submit_realtime_conversation_start(Some(ConversationStartTransport::Webrtc { + self.submit_realtime_conversation_start(Some(ThreadRealtimeStartTransport::Webrtc { sdp: offer.offer_sdp, })); self.request_redraw(); @@ -477,7 +340,7 @@ impl ChatWidget { } } - fn enqueue_realtime_audio_out(&mut self, frame: &RealtimeAudioFrame) { + fn enqueue_realtime_audio_out(&mut self, frame: &ThreadRealtimeAudioChunk) { #[cfg(not(target_os = "linux"))] { if self.realtime_conversation.audio_player.is_none() { @@ -496,16 +359,6 @@ impl ChatWidget { } } - #[cfg(not(target_os = "linux"))] - fn interrupt_realtime_audio_playback(&mut self) { - if let Some(player) = &self.realtime_conversation.audio_player { - player.clear(); - } - } - - #[cfg(target_os = "linux")] - fn interrupt_realtime_audio_playback(&mut self) {} - #[cfg(not(target_os = "linux"))] fn start_realtime_local_audio(&mut self) { if self.realtime_conversation.capture_stop_flag.is_some() { diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 904678003a..16b447d424 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -11,14 +11,14 @@ use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::skills_helpers::skill_description; use crate::skills_helpers::skill_display_name; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::SkillMetadata as ProtocolSkillMetadata; +use codex_app_server_protocol::SkillsListEntry; +use codex_app_server_protocol::SkillsListResponse; use codex_core_skills::model::SkillDependencies; use codex_core_skills::model::SkillInterface; use codex_core_skills::model::SkillMetadata; use codex_core_skills::model::SkillToolDependency; use codex_protocol::parse_command::ParsedCommand; -use codex_protocol::protocol::ListSkillsResponseEvent; -use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; -use codex_protocol::protocol::SkillsListEntry; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; @@ -135,8 +135,8 @@ impl ChatWidget { ); } - pub(crate) fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) { - let skills = skills_for_cwd(&self.config.cwd, &response.skills); + pub(crate) fn set_skills_from_response(&mut self, response: &SkillsListResponse) { + let skills = skills_for_cwd(&self.config.cwd, &response.data); self.skills_all = skills; self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all))); } @@ -222,7 +222,9 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata { }), policy: None, path_to_skills_md: skill.path.clone(), - scope: skill.scope, + scope: serde_json::to_value(skill.scope) + .and_then(serde_json::from_value) + .unwrap_or_else(|err| panic!("skill scope should map to core skill scope: {err}")), } } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 4936f385be..b64c0ef065 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -409,8 +409,8 @@ impl ChatWidget { SlashCommand::TestApproval => { use std::collections::HashMap; - use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; - use codex_protocol::protocol::FileChange; + use crate::approval_events::ApplyPatchApprovalRequestEvent; + use crate::diff_model::FileChange; self.on_apply_patch_approval_request( "1".to_string(), @@ -705,9 +705,8 @@ impl ChatWidget { self.request_side_conversation(parent_thread_id, Some(user_message)); } SlashCommand::Review if !trimmed.is_empty() => { - self.submit_op(AppCommand::review(ReviewRequest { - target: ReviewTarget::Custom { instructions: args }, - user_facing_hint: None, + self.submit_op(AppCommand::review(ReviewTarget::Custom { + instructions: args, })); } SlashCommand::Resume if !trimmed.is_empty() => { diff --git a/codex-rs/tui/src/chatwidget/test_events.rs b/codex-rs/tui/src/chatwidget/test_events.rs new file mode 100644 index 0000000000..c30aa372ca --- /dev/null +++ b/codex-rs/tui/src/chatwidget/test_events.rs @@ -0,0 +1,331 @@ +//! Test-only event fixtures for legacy-style ChatWidget coverage. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::approval_events::ApplyPatchApprovalRequestEvent; +use crate::approval_events::ExecApprovalRequestEvent; +use crate::session_state::SessionNetworkProxyRuntime; +use crate::session_state::ThreadSessionState; +use crate::token_usage::TokenCountEvent; +use crate::tool_activity::ExecCommandBeginEvent; +use crate::tool_activity::ExecCommandEndEvent; +use crate::tool_activity::ExecCommandOutputDeltaEvent; +use crate::tool_activity::HookCompletedEvent; +use crate::tool_activity::HookStartedEvent; +use crate::tool_activity::ImageGenerationBeginEvent; +use crate::tool_activity::ImageGenerationEndEvent; +use crate::tool_activity::McpToolCallBeginEvent; +use crate::tool_activity::McpToolCallEndEvent; +use crate::tool_activity::PatchApplyBeginEvent; +use crate::tool_activity::PatchApplyEndEvent; +use crate::tool_activity::TerminalInteractionEvent; +use crate::tool_activity::ViewImageToolCallEvent; +use crate::tool_activity::WebSearchBeginEvent; +use crate::tool_activity::WebSearchEndEvent; +use crate::turn_state::TurnAbortReason; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::McpAuthStatus; +use codex_app_server_protocol::ModelVerification; +use codex_protocol::ThreadId; +use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::approvals::GuardianAssessmentEvent; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::items::TurnItem; +use codex_protocol::mcp::Resource as McpResource; +use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; +use codex_protocol::mcp::Tool as McpTool; +use codex_protocol::memory_citation::MemoryCitation; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::request_permissions::RequestPermissionsEvent; +use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; + +use super::mcp_startup::McpStartupStatus; +use super::user_messages::UserMessageEvent; + +#[derive(Debug, Clone)] +pub(crate) struct Event { + pub(crate) id: String, + pub(crate) msg: EventMsg, +} + +#[derive(Debug, Clone)] +pub(crate) struct SessionConfiguredEvent { + pub(crate) session_id: ThreadId, + pub(crate) forked_from_id: Option, + pub(crate) thread_name: Option, + pub(crate) model: String, + #[allow(dead_code)] + pub(crate) model_provider_id: String, + pub(crate) service_tier: Option, + pub(crate) approval_policy: AskForApproval, + pub(crate) approvals_reviewer: ApprovalsReviewer, + pub(crate) permission_profile: PermissionProfile, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) reasoning_effort: Option, + pub(crate) history_log_id: u64, + pub(crate) history_entry_count: usize, + pub(crate) initial_messages: Option>, + pub(crate) network_proxy: Option, + pub(crate) rollout_path: Option, +} + +impl SessionConfiguredEvent { + pub(crate) fn into_session(self) -> (ThreadSessionState, Option>) { + ( + ThreadSessionState { + thread_id: self.session_id, + forked_from_id: self.forked_from_id, + fork_parent_title: None, + thread_name: self.thread_name, + model: self.model, + model_provider_id: self.model_provider_id, + service_tier: self.service_tier, + approval_policy: self.approval_policy, + approvals_reviewer: self.approvals_reviewer, + permission_profile: self.permission_profile, + cwd: self.cwd, + instruction_source_paths: Vec::new(), + reasoning_effort: self.reasoning_effort, + history_log_id: self.history_log_id, + history_entry_count: u64::try_from(self.history_entry_count).unwrap_or(u64::MAX), + network_proxy: self.network_proxy, + rollout_path: self.rollout_path, + }, + self.initial_messages, + ) + } +} + +#[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] +pub(crate) enum EventMsg { + Error(ErrorEvent), + Warning(WarningEvent), + GuardianWarning(WarningEvent), + ModelVerification(ModelVerificationEvent), + ThreadRolledBack(ThreadRolledBackEvent), + TurnStarted(TurnStartedEvent), + TurnComplete(TurnCompleteEvent), + TokenCount(TokenCountEvent), + AgentMessage(AgentMessageEvent), + UserMessage(UserMessageEvent), + AgentMessageDelta(AgentMessageDeltaEvent), + AgentReasoning(AgentReasoningEvent), + AgentReasoningDelta(AgentReasoningDeltaEvent), + AgentReasoningRawContent(AgentReasoningRawContentEvent), + AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent), + SessionConfigured(SessionConfiguredEvent), + ThreadNameUpdated(ThreadNameUpdatedEvent), + McpStartupUpdate(McpStartupUpdateEvent), + McpStartupComplete(McpStartupCompleteEvent), + McpToolCallBegin(McpToolCallBeginEvent), + McpToolCallEnd(McpToolCallEndEvent), + WebSearchBegin(WebSearchBeginEvent), + WebSearchEnd(WebSearchEndEvent), + ImageGenerationBegin(ImageGenerationBeginEvent), + ImageGenerationEnd(ImageGenerationEndEvent), + ExecCommandBegin(ExecCommandBeginEvent), + ExecCommandOutputDelta(ExecCommandOutputDeltaEvent), + TerminalInteraction(TerminalInteractionEvent), + ExecCommandEnd(ExecCommandEndEvent), + ViewImageToolCall(ViewImageToolCallEvent), + ExecApprovalRequest(ExecApprovalRequestEvent), + RequestPermissions(RequestPermissionsEvent), + RequestUserInput(RequestUserInputEvent), + ElicitationRequest(ElicitationRequestEvent), + ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent), + GuardianAssessment(GuardianAssessmentEvent), + DeprecationNotice(DeprecationNoticeEvent), + BackgroundEvent(BackgroundEventEvent), + UndoStarted(UndoStartedEvent), + UndoCompleted(UndoCompletedEvent), + StreamError(StreamErrorEvent), + PatchApplyBegin(PatchApplyBeginEvent), + PatchApplyEnd(PatchApplyEndEvent), + TurnDiff(TurnDiffEvent), + McpListToolsResponse(McpListToolsResponseEvent), + ListSkillsResponse(codex_app_server_protocol::SkillsListResponse), + SkillsUpdateAvailable, + PlanUpdate(UpdatePlanArgs), + TurnAborted(TurnAbortedEvent), + ShutdownComplete, + EnteredReviewMode(String), + ExitedReviewMode(ExitedReviewModeEvent), + ItemCompleted(ItemCompletedEvent), + HookStarted(HookStartedEvent), + HookCompleted(HookCompletedEvent), + PlanDelta(PlanDeltaEvent), +} + +#[derive(Debug, Clone)] +pub(crate) struct ErrorEvent { + pub(crate) message: String, + pub(crate) codex_error_info: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct WarningEvent { + pub(crate) message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ModelVerificationEvent { + pub(crate) verifications: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct TurnCompleteEvent { + pub(crate) turn_id: String, + pub(crate) last_agent_message: Option, + pub(crate) completed_at: Option, + pub(crate) duration_ms: Option, + pub(crate) time_to_first_token_ms: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct TurnStartedEvent { + pub(crate) turn_id: String, + pub(crate) started_at: Option, + pub(crate) model_context_window: Option, + pub(crate) collaboration_mode_kind: codex_protocol::config_types::ModeKind, +} + +#[derive(Debug, Clone)] +pub(crate) struct AgentMessageEvent { + pub(crate) message: String, + pub(crate) phase: Option, + pub(crate) memory_citation: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct AgentMessageDeltaEvent { + pub(crate) delta: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct AgentReasoningEvent { + pub(crate) text: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct AgentReasoningRawContentEvent { + pub(crate) text: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct AgentReasoningRawContentDeltaEvent { + pub(crate) delta: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct AgentReasoningDeltaEvent { + pub(crate) delta: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct BackgroundEventEvent { + pub(crate) message: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct DeprecationNoticeEvent { + pub(crate) summary: String, + pub(crate) details: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct UndoStartedEvent { + pub(crate) message: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct UndoCompletedEvent { + pub(crate) success: bool, + pub(crate) message: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct ThreadRolledBackEvent { + pub(crate) num_turns: u32, +} + +#[derive(Debug, Clone)] +pub(crate) struct StreamErrorEvent { + pub(crate) message: String, + pub(crate) codex_error_info: Option, + pub(crate) additional_details: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct TurnDiffEvent { + pub(crate) unified_diff: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct McpListToolsResponseEvent { + pub(crate) tools: HashMap, + pub(crate) resources: HashMap>, + pub(crate) resource_templates: HashMap>, + pub(crate) auth_statuses: HashMap, +} + +#[derive(Debug, Clone)] +pub(crate) struct McpStartupUpdateEvent { + pub(crate) server: String, + pub(crate) status: McpStartupStatus, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct McpStartupCompleteEvent { + pub(crate) ready: Vec, + pub(crate) failed: Vec, + pub(crate) cancelled: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct McpStartupFailure { + pub(crate) server: String, + pub(crate) error: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct ThreadNameUpdatedEvent { + pub(crate) thread_id: ThreadId, + pub(crate) thread_name: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct TurnAbortedEvent { + pub(crate) turn_id: Option, + pub(crate) reason: TurnAbortReason, + pub(crate) completed_at: Option, + pub(crate) duration_ms: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct ItemCompletedEvent { + pub(crate) thread_id: ThreadId, + pub(crate) turn_id: String, + pub(crate) item: TurnItem, +} + +#[derive(Debug, Clone)] +pub(crate) struct PlanDeltaEvent { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) item_id: String, + pub(crate) delta: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct ExitedReviewModeEvent; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1641dc7e32..f5a266ea97 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,19 +1,45 @@ //! Exercises `ChatWidget` event handling and rendering invariants. //! -//! These tests treat the widget as the adapter between `codex_protocol::protocol::EventMsg` inputs and -//! the TUI output. Many assertions are snapshot-based so that layout regressions and status/header -//! changes show up as stable, reviewable diffs. +//! These tests cover both app-server-native inputs and focused widget helpers. Many assertions are +//! snapshot-based so that layout regressions and status/header changes show up as stable, +//! reviewable diffs. +pub(super) use super::mcp_startup::McpStartupStatus; pub(super) use super::*; +pub(super) use crate::app_command::AppCommand as Op; pub(super) use crate::app_event::AppEvent; pub(super) use crate::app_event::ExitMode; #[cfg(not(target_os = "linux"))] pub(super) use crate::app_event::RealtimeAudioDeviceKind; pub(super) use crate::app_event_sender::AppEventSender; +pub(super) use crate::approval_events::ApplyPatchApprovalRequestEvent; +pub(super) use crate::approval_events::ExecApprovalRequestEvent; pub(super) use crate::bottom_pane::LocalImageAttachment; pub(super) use crate::bottom_pane::MentionBinding; pub(super) use crate::bottom_pane::QueuedInputAction; pub(super) use crate::chatwidget::realtime::RealtimeConversationPhase; +pub(super) use crate::chatwidget::test_events::AgentMessageDeltaEvent; +pub(super) use crate::chatwidget::test_events::AgentMessageEvent; +pub(super) use crate::chatwidget::test_events::AgentReasoningDeltaEvent; +pub(super) use crate::chatwidget::test_events::AgentReasoningEvent; +pub(super) use crate::chatwidget::test_events::BackgroundEventEvent; +pub(super) use crate::chatwidget::test_events::ErrorEvent; +pub(super) use crate::chatwidget::test_events::Event; +pub(super) use crate::chatwidget::test_events::EventMsg; +pub(super) use crate::chatwidget::test_events::ExitedReviewModeEvent; +pub(super) use crate::chatwidget::test_events::ItemCompletedEvent; +pub(super) use crate::chatwidget::test_events::McpStartupCompleteEvent; +pub(super) use crate::chatwidget::test_events::McpStartupUpdateEvent; +pub(super) use crate::chatwidget::test_events::ModelVerificationEvent; +pub(super) use crate::chatwidget::test_events::SessionConfiguredEvent; +pub(super) use crate::chatwidget::test_events::StreamErrorEvent; +pub(super) use crate::chatwidget::test_events::ThreadRolledBackEvent; +pub(super) use crate::chatwidget::test_events::TurnCompleteEvent; +pub(super) use crate::chatwidget::test_events::TurnStartedEvent; +pub(super) use crate::chatwidget::test_events::UndoCompletedEvent; +pub(super) use crate::chatwidget::test_events::UndoStartedEvent; +pub(super) use crate::chatwidget::test_events::WarningEvent; +pub(super) use crate::diff_model::FileChange; pub(super) use crate::history_cell::UserHistoryCell; pub(super) use crate::legacy_core::config::Config; pub(super) use crate::legacy_core::config::ConfigBuilder; @@ -24,6 +50,16 @@ pub(super) use crate::test_backend::VT100Backend; pub(super) use crate::test_support::PathBufExt; pub(super) use crate::test_support::test_path_buf; pub(super) use crate::test_support::test_path_display; +pub(super) use crate::token_usage::TokenCountEvent; +pub(super) use crate::token_usage::TokenUsage; +pub(super) use crate::token_usage::TokenUsageInfo; +pub(super) use crate::tool_activity::ExecCommandBeginEvent; +pub(super) use crate::tool_activity::ExecCommandEndEvent; +pub(super) use crate::tool_activity::ImageGenerationEndEvent; +pub(super) use crate::tool_activity::PatchApplyBeginEvent; +pub(super) use crate::tool_activity::PatchApplyEndEvent; +pub(super) use crate::tool_activity::TerminalInteractionEvent; +pub(super) use crate::tool_activity::ViewImageToolCallEvent; pub(super) use crate::tui::FrameRequester; pub(super) use assert_matches::assert_matches; pub(super) use codex_app_server_protocol::AddCreditsNudgeCreditType; @@ -33,15 +69,19 @@ pub(super) use codex_app_server_protocol::AdditionalNetworkPermissions as AppSer pub(super) use codex_app_server_protocol::AdditionalPermissionProfile as AppServerAdditionalPermissionProfile; pub(super) use codex_app_server_protocol::AppSummary; pub(super) use codex_app_server_protocol::AutoReviewDecisionSource as AppServerGuardianApprovalReviewDecisionSource; +pub(super) use codex_app_server_protocol::CodexErrorInfo; pub(super) use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; pub(super) use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus; pub(super) use codex_app_server_protocol::CollabAgentTool as AppServerCollabAgentTool; pub(super) use codex_app_server_protocol::CollabAgentToolCallStatus as AppServerCollabAgentToolCallStatus; pub(super) use codex_app_server_protocol::CommandAction as AppServerCommandAction; pub(super) use codex_app_server_protocol::CommandExecutionRequestApprovalParams as AppServerCommandExecutionRequestApprovalParams; +pub(super) use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; pub(super) use codex_app_server_protocol::CommandExecutionSource as AppServerCommandExecutionSource; +pub(super) use codex_app_server_protocol::CommandExecutionStatus as CoreExecCommandStatus; pub(super) use codex_app_server_protocol::CommandExecutionStatus as AppServerCommandExecutionStatus; pub(super) use codex_app_server_protocol::ConfigWarningNotification; +pub(super) use codex_app_server_protocol::CreditsSnapshot; pub(super) use codex_app_server_protocol::ErrorNotification; pub(super) use codex_app_server_protocol::FileUpdateChange; pub(super) use codex_app_server_protocol::GuardianApprovalReview; @@ -70,8 +110,11 @@ pub(super) use codex_app_server_protocol::MarketplaceInterface; pub(super) use codex_app_server_protocol::McpServerStartupState; pub(super) use codex_app_server_protocol::McpServerStatusDetail; pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification; +pub(super) use codex_app_server_protocol::ModelVerification as CoreModelVerification; pub(super) use codex_app_server_protocol::ModelVerification as AppServerModelVerification; pub(super) use codex_app_server_protocol::ModelVerificationNotification; +pub(super) use codex_app_server_protocol::NonSteerableTurnKind; +pub(super) use codex_app_server_protocol::PatchApplyStatus as CorePatchApplyStatus; pub(super) use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus; pub(super) use codex_app_server_protocol::PatchChangeKind; pub(super) use codex_app_server_protocol::PermissionsRequestApprovalParams as AppServerPermissionsRequestApprovalParams; @@ -84,11 +127,17 @@ pub(super) use codex_app_server_protocol::PluginMarketplaceEntry; pub(super) use codex_app_server_protocol::PluginReadResponse; pub(super) use codex_app_server_protocol::PluginSource; pub(super) use codex_app_server_protocol::PluginSummary; +pub(super) use codex_app_server_protocol::RateLimitReachedType; +pub(super) use codex_app_server_protocol::RateLimitSnapshot; +pub(super) use codex_app_server_protocol::RateLimitWindow; pub(super) use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; +pub(super) use codex_app_server_protocol::ReviewTarget; pub(super) use codex_app_server_protocol::ServerNotification; pub(super) use codex_app_server_protocol::SkillSummary; pub(super) use codex_app_server_protocol::ThreadClosedNotification; pub(super) use codex_app_server_protocol::ThreadItem as AppServerThreadItem; +pub(super) use codex_app_server_protocol::ThreadRealtimeClosedNotification; +pub(super) use codex_app_server_protocol::ThreadRealtimeErrorNotification; pub(super) use codex_app_server_protocol::Turn as AppServerTurn; pub(super) use codex_app_server_protocol::TurnCompletedNotification; pub(super) use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -116,6 +165,14 @@ pub(super) use codex_otel::RuntimeMetricsSummary; pub(super) use codex_otel::SessionTelemetry; pub(super) use codex_protocol::ThreadId; pub(super) use codex_protocol::account::PlanType; +pub(super) use codex_protocol::approvals::ExecPolicyAmendment; +pub(super) use codex_protocol::approvals::GuardianAssessmentAction; +pub(super) use codex_protocol::approvals::GuardianAssessmentDecisionSource; +pub(super) use codex_protocol::approvals::GuardianAssessmentEvent; +pub(super) use codex_protocol::approvals::GuardianAssessmentStatus; +pub(super) use codex_protocol::approvals::GuardianCommandSource; +pub(super) use codex_protocol::approvals::GuardianRiskLevel; +pub(super) use codex_protocol::approvals::GuardianUserAuthorization; pub(super) use codex_protocol::config_types::CollaborationMode; pub(super) use codex_protocol::config_types::ModeKind; pub(super) use codex_protocol::config_types::Personality; @@ -139,70 +196,6 @@ pub(super) use codex_protocol::parse_command::ParsedCommand; pub(super) use codex_protocol::plan_tool::PlanItemArg; pub(super) use codex_protocol::plan_tool::StepStatus; pub(super) use codex_protocol::plan_tool::UpdatePlanArgs; -pub(super) use codex_protocol::protocol::AgentMessageDeltaEvent; -pub(super) use codex_protocol::protocol::AgentMessageEvent; -pub(super) use codex_protocol::protocol::AgentReasoningDeltaEvent; -pub(super) use codex_protocol::protocol::AgentReasoningEvent; -pub(super) use codex_protocol::protocol::AgentStatus; -pub(super) use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; -pub(super) use codex_protocol::protocol::BackgroundEventEvent; -pub(super) use codex_protocol::protocol::CodexErrorInfo; -pub(super) use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -pub(super) use codex_protocol::protocol::CollabAgentSpawnEndEvent; -pub(super) use codex_protocol::protocol::CreditsSnapshot; -pub(super) use codex_protocol::protocol::ErrorEvent; -pub(super) use codex_protocol::protocol::Event; -pub(super) use codex_protocol::protocol::EventMsg; -pub(super) use codex_protocol::protocol::ExecApprovalRequestEvent; -pub(super) use codex_protocol::protocol::ExecCommandBeginEvent; -pub(super) use codex_protocol::protocol::ExecCommandEndEvent; -pub(super) use codex_protocol::protocol::ExecCommandSource; -pub(super) use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; -pub(super) use codex_protocol::protocol::ExecPolicyAmendment; -pub(super) use codex_protocol::protocol::ExitedReviewModeEvent; -pub(super) use codex_protocol::protocol::FileChange; -pub(super) use codex_protocol::protocol::GuardianAssessmentAction; -pub(super) use codex_protocol::protocol::GuardianAssessmentDecisionSource; -pub(super) use codex_protocol::protocol::GuardianAssessmentEvent; -pub(super) use codex_protocol::protocol::GuardianAssessmentStatus; -pub(super) use codex_protocol::protocol::GuardianCommandSource; -pub(super) use codex_protocol::protocol::GuardianRiskLevel; -pub(super) use codex_protocol::protocol::GuardianUserAuthorization; -pub(super) use codex_protocol::protocol::ImageGenerationEndEvent; -pub(super) use codex_protocol::protocol::ItemCompletedEvent; -pub(super) use codex_protocol::protocol::McpStartupCompleteEvent; -pub(super) use codex_protocol::protocol::McpStartupStatus; -pub(super) use codex_protocol::protocol::McpStartupUpdateEvent; -pub(super) use codex_protocol::protocol::ModelVerification as CoreModelVerification; -pub(super) use codex_protocol::protocol::ModelVerificationEvent; -pub(super) use codex_protocol::protocol::NonSteerableTurnKind; -pub(super) use codex_protocol::protocol::Op; -pub(super) use codex_protocol::protocol::PatchApplyBeginEvent; -pub(super) use codex_protocol::protocol::PatchApplyEndEvent; -pub(super) use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; -pub(super) use codex_protocol::protocol::RateLimitReachedType; -pub(super) use codex_protocol::protocol::RateLimitSnapshot; -pub(super) use codex_protocol::protocol::RateLimitWindow; -pub(super) use codex_protocol::protocol::RealtimeConversationClosedEvent; -pub(super) use codex_protocol::protocol::RealtimeConversationRealtimeEvent; -pub(super) use codex_protocol::protocol::RealtimeEvent; -pub(super) use codex_protocol::protocol::ReviewRequest; -pub(super) use codex_protocol::protocol::ReviewTarget; -pub(super) use codex_protocol::protocol::SessionConfiguredEvent; -pub(super) use codex_protocol::protocol::SessionSource; -pub(super) use codex_protocol::protocol::SkillScope; -pub(super) use codex_protocol::protocol::StreamErrorEvent; -pub(super) use codex_protocol::protocol::TerminalInteractionEvent; -pub(super) use codex_protocol::protocol::ThreadRolledBackEvent; -pub(super) use codex_protocol::protocol::TokenCountEvent; -pub(super) use codex_protocol::protocol::TokenUsage; -pub(super) use codex_protocol::protocol::TokenUsageInfo; -pub(super) use codex_protocol::protocol::TurnCompleteEvent; -pub(super) use codex_protocol::protocol::TurnStartedEvent; -pub(super) use codex_protocol::protocol::UndoCompletedEvent; -pub(super) use codex_protocol::protocol::UndoStartedEvent; -pub(super) use codex_protocol::protocol::ViewImageToolCallEvent; -pub(super) use codex_protocol::protocol::WarningEvent; pub(super) use codex_protocol::request_permissions::RequestPermissionProfile; pub(super) use codex_protocol::request_user_input::RequestUserInputEvent; pub(super) use codex_protocol::request_user_input::RequestUserInputQuestion; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 9c2e1c83d4..13b86e2afb 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -6,31 +6,54 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; let sender_thread_id = ThreadId::new(); let spawned_thread_id = ThreadId::new(); + chat.set_collab_agent_metadata( + spawned_thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + ); - chat.handle_codex_event(Event { - id: "spawn-begin".into(), - msg: EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { - call_id: "call-spawn".to_string(), - sender_thread_id, - prompt: "Explore the repo".to_string(), - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::High, + chat.handle_server_notification( + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: AppServerThreadItem::CollabAgentToolCall { + id: "call-spawn".to_string(), + tool: AppServerCollabAgentTool::SpawnAgent, + status: AppServerCollabAgentToolCallStatus::InProgress, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some("Explore the repo".to_string()), + model: Some("gpt-5".to_string()), + reasoning_effort: Some(ReasoningEffortConfig::High), + agents_states: HashMap::new(), + }, }), - }); - chat.handle_codex_event(Event { - id: "spawn-end".into(), - msg: EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { - call_id: "call-spawn".to_string(), - sender_thread_id, - new_thread_id: Some(spawned_thread_id), - new_agent_nickname: Some("Robie".to_string()), - new_agent_role: Some("explorer".to_string()), - prompt: "Explore the repo".to_string(), - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::High, - status: AgentStatus::PendingInit, + /*replay_kind*/ None, + ); + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: AppServerThreadItem::CollabAgentToolCall { + id: "call-spawn".to_string(), + tool: AppServerCollabAgentTool::SpawnAgent, + status: AppServerCollabAgentToolCallStatus::Completed, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: vec![spawned_thread_id.to_string()], + prompt: Some("Explore the repo".to_string()), + model: None, + reasoning_effort: None, + agents_states: HashMap::from([( + spawned_thread_id.to_string(), + AppServerCollabAgentState { + status: AppServerCollabAgentStatus::PendingInit, + message: None, + }, + )]), + }, }), - }); + /*replay_kind*/ None, + ); let cells = drain_insert_history(&mut rx); let rendered = cells @@ -586,7 +609,7 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { ServerNotification::Error(ErrorNotification { error: AppServerTurnError { message: "Reconnecting... 1/5".to_string(), - codex_error_info: Some(CodexErrorInfo::Other.into()), + codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }, will_retry: true, @@ -643,7 +666,7 @@ async fn live_app_server_server_overloaded_error_renders_warning() { ServerNotification::Error(ErrorNotification { error: AppServerTurnError { message: "server overloaded".to_string(), - codex_error_info: Some(CodexErrorInfo::ServerOverloaded.into()), + codex_error_info: Some(CodexErrorInfo::ServerOverloaded), additional_details: None, }, will_retry: false, @@ -684,7 +707,7 @@ async fn live_app_server_cyber_policy_error_renders_dedicated_notice() { ServerNotification::Error(ErrorNotification { error: AppServerTurnError { message: "server fallback message".to_string(), - codex_error_info: Some(CodexErrorInfo::CyberPolicy.into()), + codex_error_info: Some(CodexErrorInfo::CyberPolicy), additional_details: None, }, will_retry: false, diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index 2e5c51307c..ad90cf14d1 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -126,9 +126,9 @@ fn app_server_exec_approval_request_preserves_permissions_context() { assert_eq!( request.network_approval_context, - Some(codex_protocol::protocol::NetworkApprovalContext { + Some(codex_app_server_protocol::NetworkApprovalContext { host: "example.com".to_string(), - protocol: codex_protocol::protocol::NetworkApprovalProtocol::Socks5Tcp, + protocol: codex_app_server_protocol::NetworkApprovalProtocol::Socks5Tcp, }) ); assert_eq!( @@ -222,7 +222,10 @@ async fn exec_approval_uses_approval_id_when_present() { } = app_ev { assert_eq!(id, "approval-subcommand"); - assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved); + assert_matches!( + decision, + codex_app_server_protocol::CommandExecutionApprovalDecision::Accept + ); found = true; break; } diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a99bc15183..ef179789ee 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1,4 +1,11 @@ use super::*; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; +use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; use pretty_assertions::assert_eq; #[tokio::test] @@ -7,7 +14,7 @@ async fn submission_preserves_text_elements_and_local_images() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -91,27 +98,28 @@ async fn submission_includes_configured_permission_profile() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let expected_permission_profile = PermissionProfile::Managed { - network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted, - file_system: codex_protocol::models::ManagedFileSystemPermissions::Restricted { + let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![ - codex_protocol::permissions::FileSystemSandboxEntry { - path: codex_protocol::permissions::FileSystemPath::Special { - value: codex_protocol::permissions::FileSystemSpecialPath::Root, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, }, - access: codex_protocol::permissions::FileSystemAccessMode::Read, + access: FileSystemAccessMode::Read, }, - codex_protocol::permissions::FileSystemSandboxEntry { - path: codex_protocol::permissions::FileSystemPath::GlobPattern { + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { pattern: "/home/user/project/secrets/**".to_string(), }, - access: codex_protocol::permissions::FileSystemAccessMode::None, + access: FileSystemAccessMode::None, }, ], glob_scan_max_depth: None, }, - }; - let configured = codex_protocol::protocol::SessionConfiguredEvent { + } + .into(); + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -148,7 +156,7 @@ async fn submission_includes_configured_permission_profile() { } => permission_profile, other => panic!("expected Op::UserTurn, got {other:?}"), }; - assert_eq!(permission_profile, Some(expected_permission_profile)); + assert_eq!(permission_profile, expected_permission_profile); } #[tokio::test] @@ -157,11 +165,12 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let expected_permission_profile = PermissionProfile::Managed { - network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted, - file_system: codex_protocol::models::ManagedFileSystemPermissions::Unrestricted, - }; - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Unrestricted, + } + .into(); + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -195,7 +204,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { } => permission_profile, other => panic!("expected Op::UserTurn, got {other:?}"), }; - assert_eq!(permission_profile, Some(expected_permission_profile)); + assert_eq!(permission_profile, expected_permission_profile); } #[tokio::test] @@ -204,7 +213,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -299,7 +308,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -364,7 +373,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -404,7 +413,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -444,7 +453,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -487,7 +496,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -522,7 +531,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { dependencies: None, policy: None, path_to_skills_md: repo_skill_path, - scope: SkillScope::Repo, + scope: crate::test_support::skill_scope_repo(), }, SkillMetadata { name: "figma".to_string(), @@ -532,7 +541,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { dependencies: None, policy: None, path_to_skills_md: user_skill_path.clone(), - scope: SkillScope::User, + scope: crate::test_support::skill_scope_user(), }, ])); @@ -731,7 +740,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { chat.handle_codex_event(Event { id: "interrupt".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, completed_at: None, @@ -1178,7 +1187,7 @@ async fn interrupt_restores_queued_messages_into_composer() { // Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed). chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, completed_at: None, @@ -1219,7 +1228,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, completed_at: None, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d3d5de1f12..47d3c623b8 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -119,7 +119,10 @@ async fn exec_approval_uses_approval_id_when_present() { } = app_ev { assert_eq!(id, "approval-subcommand"); - assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved); + assert_matches!( + decision, + codex_app_server_protocol::CommandExecutionApprovalDecision::Accept + ); found = true; break; } @@ -1049,7 +1052,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -1183,7 +1186,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { chat.config .permissions .approval_policy - .set(AskForApproval::OnRequest)?; + .set(AskForApproval::OnRequest.to_core())?; // Inject an exec approval request to display the approval modal. let ev = ExecApprovalRequestEvent { call_id: "call-approve-cmd".into(), @@ -1246,7 +1249,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { chat.config .permissions .approval_policy - .set(AskForApproval::OnRequest)?; + .set(AskForApproval::OnRequest.to_core())?; let ev = ExecApprovalRequestEvent { call_id: "call-approve-cmd-noreason".into(), @@ -1296,7 +1299,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() chat.config .permissions .approval_policy - .set(AskForApproval::OnRequest)?; + .set(AskForApproval::OnRequest.to_core())?; let script = "python - <<'PY'\nprint('hello')\nPY".to_string(); let command = vec!["bash".into(), "-lc".into(), script]; @@ -1344,7 +1347,7 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { chat.config .permissions .approval_policy - .set(AskForApproval::OnRequest)?; + .set(AskForApproval::OnRequest.to_core())?; // Build a small changeset and a reason/grant_root to exercise the prompt text. let mut changes = HashMap::new(); @@ -1391,7 +1394,7 @@ async fn interrupt_preserves_unified_exec_processes() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, completed_at: None, @@ -1439,7 +1442,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, completed_at: None, @@ -1717,7 +1720,10 @@ async fn apply_patch_approval_sends_op_with_call_id() { } = app_ev { assert_eq!(id, "call-999"); - assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved); + assert_matches!( + decision, + codex_app_server_protocol::FileChangeApprovalDecision::Accept + ); found = true; break; } @@ -1765,7 +1771,10 @@ async fn apply_patch_full_flow_integration_like() { match forwarded { Op::PatchApproval { id, decision } => { assert_eq!(id, "call-1"); - assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved); + assert_matches!( + decision, + codex_app_server_protocol::FileChangeApprovalDecision::Accept + ); } other => panic!("unexpected op forwarded: {other:?}"), } @@ -1811,7 +1820,7 @@ async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> { chat.config .permissions .approval_policy - .set(AskForApproval::OnRequest)?; + .set(AskForApproval::OnRequest.to_core())?; // Simulate a patch approval request from backend let mut changes = HashMap::new(); @@ -1862,7 +1871,7 @@ async fn apply_patch_request_omits_diff_summary_from_modal() -> anyhow::Result<( chat.config .permissions .approval_policy - .set(AskForApproval::OnRequest)?; + .set(AskForApproval::OnRequest.to_core())?; // Simulate backend asking to apply a patch adding two lines to README.md let mut changes = HashMap::new(); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d26a503a89..570805f272 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -99,8 +99,8 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: percent, - window_minutes: Some(60), + used_percent: percent.round() as i32, + window_duration_mins: Some(60), resets_at: None, }), secondary: None, @@ -122,7 +122,7 @@ pub(super) fn test_session_telemetry(config: &Config, model: &str) -> SessionTel "test_originator".to_string(), /*log_user_prompts*/ false, "test".to_string(), - SessionSource::Cli, + crate::test_support::session_source_cli(), ) } @@ -1016,7 +1016,7 @@ pub(super) fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { } pub(super) async fn assert_hook_events_snapshot( - event_name: codex_protocol::protocol::HookEventName, + event_name: codex_app_server_protocol::HookEventName, run_id: &str, status_message: &str, snapshot_name: &str, @@ -1025,18 +1025,18 @@ pub(super) async fn assert_hook_events_snapshot( chat.handle_codex_event(Event { id: "hook-1".into(), - msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { + msg: EventMsg::HookStarted(crate::tool_activity::HookStartedEvent { turn_id: None, - run: codex_protocol::protocol::HookRunSummary { + run: codex_app_server_protocol::HookRunSummary { id: run_id.to_string(), event_name, - handler_type: codex_protocol::protocol::HookHandlerType::Command, - execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Turn, + handler_type: codex_app_server_protocol::HookHandlerType::Command, + execution_mode: codex_app_server_protocol::HookExecutionMode::Sync, + scope: codex_app_server_protocol::HookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), - source: codex_protocol::protocol::HookSource::User, + source: codex_app_server_protocol::HookSource::User, display_order: 0, - status: codex_protocol::protocol::HookRunStatus::Running, + status: codex_app_server_protocol::HookRunStatus::Running, status_message: Some(status_message.to_string()), started_at: 1, completed_at: None, @@ -1060,29 +1060,29 @@ pub(super) async fn assert_hook_events_snapshot( chat.handle_codex_event(Event { id: "hook-1".into(), - msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { + msg: EventMsg::HookCompleted(crate::tool_activity::HookCompletedEvent { turn_id: None, - run: codex_protocol::protocol::HookRunSummary { + run: codex_app_server_protocol::HookRunSummary { id: run_id.to_string(), event_name, - handler_type: codex_protocol::protocol::HookHandlerType::Command, - execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Turn, + handler_type: codex_app_server_protocol::HookHandlerType::Command, + execution_mode: codex_app_server_protocol::HookExecutionMode::Sync, + scope: codex_app_server_protocol::HookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), - source: codex_protocol::protocol::HookSource::User, + source: codex_app_server_protocol::HookSource::User, display_order: 0, - status: codex_protocol::protocol::HookRunStatus::Completed, + status: codex_app_server_protocol::HookRunStatus::Completed, status_message: Some(status_message.to_string()), started_at: 1, completed_at: Some(11), duration_ms: Some(10), entries: vec![ - codex_protocol::protocol::HookOutputEntry { - kind: codex_protocol::protocol::HookOutputEntryKind::Warning, + codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Warning, text: "Heads up from the hook".to_string(), }, - codex_protocol::protocol::HookOutputEntry { - kind: codex_protocol::protocol::HookOutputEntryKind::Context, + codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Context, text: "Remember the startup checklist.".to_string(), }, ], @@ -1098,13 +1098,13 @@ pub(super) async fn assert_hook_events_snapshot( assert_chatwidget_snapshot!(snapshot_name, combined); } -fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { +fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'static str { match event_name { - codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", - codex_protocol::protocol::HookEventName::PermissionRequest => "PermissionRequest", - codex_protocol::protocol::HookEventName::PostToolUse => "PostToolUse", - codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", - codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", - codex_protocol::protocol::HookEventName::Stop => "Stop", + codex_app_server_protocol::HookEventName::PreToolUse => "PreToolUse", + codex_app_server_protocol::HookEventName::PermissionRequest => "PermissionRequest", + codex_app_server_protocol::HookEventName::PostToolUse => "PostToolUse", + codex_app_server_protocol::HookEventName::SessionStart => "SessionStart", + codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", + codex_app_server_protocol::HookEventName::Stop => "Stop", } } diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 86346885b6..ea420e8638 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -1,11 +1,13 @@ use super::*; -use codex_protocol::protocol::FileSystemAccessMode; -use codex_protocol::protocol::FileSystemPath; -use codex_protocol::protocol::FileSystemSandboxEntry; -use codex_protocol::protocol::FileSystemSandboxKind; -use codex_protocol::protocol::FileSystemSandboxPolicy; -use codex_protocol::protocol::FileSystemSpecialPath; -use codex_protocol::protocol::NetworkSandboxPolicy; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; +use codex_app_server_protocol::NetworkAccess; +use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; +use codex_app_server_protocol::SandboxPolicy; use pretty_assertions::assert_eq; #[tokio::test] @@ -14,7 +16,7 @@ async fn resumed_initial_messages_render_history() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -127,7 +129,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -188,7 +190,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -245,7 +247,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { chat.config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); chat.config .permissions @@ -254,29 +256,33 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { chat.config.cwd = test_path_buf("/home/user/main").abs(); let expected_cwd = test_path_buf("/home/user/sub-agent").abs(); - let expected_file_system_policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, + let expected_app_server_permission_profile = AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/.secret".to_string(), + }, + access: FileSystemAccessMode::None, + }, + ], + glob_scan_max_depth: None, }, - FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/.secret".to_string(), - }, - access: FileSystemAccessMode::None, - }, - ]); - let expected_permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions( - &expected_file_system_policy, - NetworkSandboxPolicy::Restricted, - ); - let expected_sandbox = expected_permission_profile + }; + let expected_permission_profile: PermissionProfile = + expected_app_server_permission_profile.clone().into(); + let expected_core_sandbox = expected_permission_profile .to_legacy_sandbox_policy(expected_cwd.as_path()) .expect("permission profile should project to legacy sandbox policy"); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let expected_sandbox = SandboxPolicy::from(expected_core_sandbox); + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, thread_name: None, @@ -285,7 +291,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - permission_profile: expected_permission_profile.clone(), + permission_profile: expected_permission_profile, cwd: expected_cwd.clone(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -301,16 +307,14 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { }); assert_eq!( - chat.config_ref().permissions.approval_policy.value(), + AskForApproval::from(chat.config_ref().permissions.approval_policy.value()), AskForApproval::Never ); + let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); + assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( - &chat.config_ref().legacy_sandbox_policy(), - &expected_sandbox - ); - assert_eq!( - chat.config_ref().permissions.permission_profile(), - expected_permission_profile + AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()), + expected_app_server_permission_profile ); assert_eq!(&chat.config_ref().cwd, &expected_cwd); @@ -328,13 +332,15 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { async fn session_configured_external_sandbox_keeps_external_runtime_policy() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - let expected_permission_profile = PermissionProfile::External { - network: NetworkSandboxPolicy::Restricted, + let expected_app_server_permission_profile = AppServerPermissionProfile::External { + network: PermissionProfileNetworkPermissions { enabled: false }, }; - let expected_sandbox = expected_permission_profile - .to_legacy_sandbox_policy(test_path_buf("/home/user/external").as_path()) - .expect("external profile should project to legacy sandbox policy"); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let expected_permission_profile: PermissionProfile = + expected_app_server_permission_profile.clone().into(); + let expected_sandbox = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }; + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, thread_name: None, @@ -358,20 +364,11 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { msg: EventMsg::SessionConfigured(configured), }); + let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); + assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( - &chat.config_ref().legacy_sandbox_policy(), - &expected_sandbox - ); - assert_eq!( - chat.config_ref() - .permissions - .file_system_sandbox_policy() - .kind, - FileSystemSandboxKind::ExternalSandbox, - ); - assert_eq!( - chat.config_ref().permissions.network_sandbox_policy(), - NetworkSandboxPolicy::Restricted, + AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()), + expected_app_server_permission_profile ); } @@ -383,7 +380,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -436,7 +433,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -625,10 +622,7 @@ async fn thread_snapshot_replay_preserves_agent_message_during_review_mode() { chat.handle_codex_event_replay(Event { id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }), + msg: EventMsg::EnteredReviewMode("current changes".to_string()), }); let _ = drain_insert_history(&mut rx); diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 829fa77f5d..67722fa261 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -1,12 +1,53 @@ use super::*; -use codex_protocol::models::ManagedFileSystemPermissions; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSpecialPath; -use codex_protocol::protocol::NetworkSandboxPolicy; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; +use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; use pretty_assertions::assert_eq; +fn app_server_workspace_write_profile(extra_root: AbsolutePathBuf) -> PermissionProfile { + AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { subpath: None }, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::SlashTmp, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: extra_root }, + access: FileSystemAccessMode::Write, + }, + ], + glob_scan_max_depth: None, + }, + } + .into() +} + #[tokio::test] async fn approvals_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -57,12 +98,7 @@ async fn preset_matching_accepts_workspace_write_with_extra_roots() { .into_iter() .find(|p| p.id == "auto") .expect("auto preset exists"); - let current_profile = PermissionProfile::workspace_write_with( - &[test_path_buf("/tmp/extra").abs()], - NetworkSandboxPolicy::Restricted, - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, - ); + let current_profile = app_server_workspace_write_profile(test_path_buf("/tmp/extra").abs()); let cwd = test_path_buf("/tmp/project").abs(); assert!( @@ -91,8 +127,9 @@ async fn preset_matching_does_not_treat_non_cwd_writable_profile_as_read_only() .into_iter() .find(|p| p.id == "read-only") .expect("read-only preset exists"); - let current_profile = PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Restricted { + let current_profile: PermissionProfile = AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -109,8 +146,8 @@ async fn preset_matching_does_not_treat_non_cwd_writable_profile_as_read_only() ], glob_scan_max_depth: None, }, - network: NetworkSandboxPolicy::Restricted, - }; + } + .into(); let cwd = test_path_buf("/tmp/project").abs(); assert!( @@ -208,15 +245,17 @@ async fn startup_does_not_prompt_for_windows_sandbox_when_not_requested() { async fn approvals_popup_shows_disabled_presets() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.permissions.approval_policy = - Constrained::new(AskForApproval::OnRequest, |candidate| match candidate { + chat.config.permissions.approval_policy = Constrained::new( + AskForApproval::OnRequest.to_core(), + |candidate| match AskForApproval::from(*candidate) { AskForApproval::OnRequest => Ok(()), _ => Err(invalid_value( candidate.to_string(), "this message should be printed in the description", )), - }) - .expect("construct constrained approval policy"); + }, + ) + .expect("construct constrained approval policy"); chat.open_approvals_popup(); let width = 80; @@ -245,12 +284,14 @@ async fn approvals_popup_navigation_skips_disabled() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false); - chat.config.permissions.approval_policy = - Constrained::new(AskForApproval::OnRequest, |candidate| match candidate { + chat.config.permissions.approval_policy = Constrained::new( + AskForApproval::OnRequest.to_core(), + |candidate| match AskForApproval::from(*candidate) { AskForApproval::OnRequest => Ok(()), _ => Err(invalid_value(candidate.to_string(), "[on-request]")), - }) - .expect("construct constrained approval policy"); + }, + ) + .expect("construct constrained approval policy"); chat.open_approvals_popup(); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -400,7 +441,7 @@ async fn permissions_selection_history_snapshot_full_access_to_default() { chat.config .permissions .approval_policy - .set(AskForApproval::Never) + .set(AskForApproval::Never.to_core()) .expect("set approval policy"); chat.config .permissions @@ -442,7 +483,7 @@ async fn permissions_selection_emits_history_cell_when_current_is_selected() { chat.config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); chat.config .permissions @@ -500,7 +541,7 @@ async fn permissions_selection_hides_auto_review_when_feature_disabled_even_if_a chat.config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); chat.config .permissions @@ -577,12 +618,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w let extra_root = test_path_buf("/tmp/guardian-approvals-extra").abs(); let cwd = test_project_path().abs(); - let permission_profile = PermissionProfile::workspace_write_with( - &[extra_root], - NetworkSandboxPolicy::Restricted, - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, - ); + let permission_profile = app_server_workspace_write_profile(extra_root); chat.handle_codex_event(Event { id: "session-configured-custom-workspace".to_string(), @@ -628,7 +664,7 @@ async fn permissions_selection_can_disable_auto_review() { chat.config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); chat.config .permissions @@ -668,7 +704,7 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context chat.config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); chat.config .permissions @@ -708,7 +744,6 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context cwd: None, approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - sandbox_policy: None, permission_profile: Some(PermissionProfile::workspace_write()), windows_sandbox_level: None, model: None, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index d369eb710d..829c199aab 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1186,7 +1186,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -1431,7 +1431,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); - let configured = codex_protocol::protocol::SessionConfiguredEvent { + let configured = crate::chatwidget::test_events::SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, thread_name: None, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 11505b3e71..6169870139 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -8,12 +8,14 @@ async fn realtime_error_closes_without_followup_closed_info() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.realtime_conversation.phase = RealtimeConversationPhase::Active; - chat.on_realtime_conversation_realtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::Error("boom".to_string()), + chat.on_realtime_error(ThreadRealtimeErrorNotification { + thread_id: ThreadId::new().to_string(), + message: "boom".to_string(), }); next_realtime_close_op(&mut op_rx); - chat.on_realtime_conversation_closed(RealtimeConversationClosedEvent { + chat.on_realtime_conversation_closed(ThreadRealtimeClosedNotification { + thread_id: ThreadId::new().to_string(), reason: Some("error".to_string()), }); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 8061b89fc1..ef2621736e 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -64,7 +64,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { // must be renumbered to match the combined local image list. chat.handle_codex_event(Event { id: "interrupt".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, completed_at: None, @@ -113,12 +113,7 @@ async fn entered_review_mode_uses_request_hint() { chat.handle_codex_event(Event { id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: "feature".to_string(), - }, - user_facing_hint: Some("feature branch".to_string()), - }), + msg: EventMsg::EnteredReviewMode("feature branch".to_string()), }); let cells = drain_insert_history(&mut rx); @@ -134,10 +129,7 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { chat.handle_codex_event(Event { id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }), + msg: EventMsg::EnteredReviewMode("current changes".to_string()), }); let cells = drain_insert_history(&mut rx); @@ -152,12 +144,7 @@ async fn live_core_review_prompt_item_is_not_rendered() { chat.handle_codex_event(Event { id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: "main".to_string(), - }, - user_facing_hint: Some("changes against 'main'".to_string()), - }), + msg: EventMsg::EnteredReviewMode("changes against 'main'".to_string()), }); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1); @@ -235,12 +222,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages }); chat.handle_codex_event(Event { id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: "feature".to_string(), - }, - user_facing_hint: Some("feature branch".to_string()), - }), + msg: EventMsg::EnteredReviewMode("feature branch".to_string()), }); let _ = drain_insert_history(&mut rx); chat.queued_user_messages @@ -303,9 +285,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages chat.handle_codex_event(Event { id: "review-exit".into(), - msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: None, - }), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent), }); chat.handle_codex_event(Event { id: "turn-complete".into(), @@ -358,10 +338,7 @@ async fn live_agent_message_renders_during_review_mode() { chat.handle_codex_event(Event { id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }), + msg: EventMsg::EnteredReviewMode("current changes".to_string()), }); let _ = drain_insert_history(&mut rx); @@ -399,12 +376,7 @@ async fn review_restores_context_window_indicator() { chat.handle_codex_event(Event { id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: "feature".to_string(), - }, - user_facing_hint: Some("feature branch".to_string()), - }), + msg: EventMsg::EnteredReviewMode("feature branch".to_string()), }); chat.handle_codex_event(Event { @@ -418,9 +390,7 @@ async fn review_restores_context_window_indicator() { chat.handle_codex_event(Event { id: "review-end".into(), - msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: None, - }), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent), }); let _ = drain_insert_history(&mut rx); @@ -1022,7 +992,7 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { chat.handle_codex_event(Event { id: "replaced".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, completed_at: None, @@ -1224,14 +1194,11 @@ async fn custom_prompt_submit_sends_review_op() { // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt let evt = rx.try_recv().expect("expected one app event"); match evt { - AppEvent::CodexOp(Op::Review { review_request }) => { + AppEvent::CodexOp(Op::Review { target }) => { assert_eq!( - review_request, - ReviewRequest { - target: ReviewTarget::Custom { - instructions: "please audit dependencies".to_string(), - }, - user_facing_hint: None, + target, + ReviewTarget::Custom { + instructions: "please audit dependencies".to_string(), } ); } @@ -1265,7 +1232,7 @@ async fn interrupt_exec_marks_failed_snapshot() { // cause the active exec cell to be finalized as failed and flushed. chat.handle_codex_event(Event { id: "call-int".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, completed_at: None, @@ -1304,7 +1271,7 @@ async fn interrupted_turn_error_message_snapshot() { // Abort the turn (like pressing Esc) and drain inserted history. chat.handle_codex_event(Event { id: "task-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, completed_at: None, @@ -1400,7 +1367,7 @@ async fn direct_budget_limited_turn_uses_budget_message_snapshot() { }); chat.handle_codex_event(Event { id: "task-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::BudgetLimited, completed_at: None, @@ -1431,7 +1398,7 @@ async fn budget_limited_turn_restores_queued_input_without_submitting() { }); chat.handle_codex_event(Event { id: "task-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::BudgetLimited, completed_at: None, @@ -1469,7 +1436,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { chat.handle_codex_event(Event { id: "task-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, completed_at: None, @@ -1567,7 +1534,7 @@ async fn review_ended_keeps_unified_exec_processes() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, completed_at: None, @@ -1612,10 +1579,7 @@ async fn enter_submits_steer_while_review_is_running() { chat.handle_codex_event(Event { id: "review-1".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: Some("current changes".to_string()), - }), + msg: EventMsg::EnteredReviewMode("current changes".to_string()), }); let _ = drain_insert_history(&mut rx); @@ -1661,10 +1625,7 @@ async fn review_queues_user_messages_snapshot() { chat.handle_codex_event(Event { id: "review-1".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: Some("current changes".to_string()), - }), + msg: EventMsg::EnteredReviewMode("current changes".to_string()), }); let _ = drain_insert_history(&mut rx); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index a0ba30e645..fb92507a6b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -131,24 +131,18 @@ async fn queued_slash_review_with_args_dispatches_after_active_turn() { match op_rx.try_recv() { Ok(Op::AddToHistory { .. }) => match op_rx.try_recv() { - Ok(Op::Review { review_request }) => assert_eq!( - review_request, - ReviewRequest { - target: ReviewTarget::Custom { - instructions: "check regressions".to_string(), - }, - user_facing_hint: None, + Ok(Op::Review { target }) => assert_eq!( + target, + ReviewTarget::Custom { + instructions: "check regressions".to_string(), } ), other => panic!("expected queued /review to submit review op, got {other:?}"), }, - Ok(Op::Review { review_request }) => assert_eq!( - review_request, - ReviewRequest { - target: ReviewTarget::Custom { - instructions: "check regressions".to_string(), - }, - user_facing_hint: None, + Ok(Op::Review { target }) => assert_eq!( + target, + ReviewTarget::Custom { + instructions: "check regressions".to_string(), } ), other => panic!("expected queued /review to submit review op, got {other:?}"), @@ -453,7 +447,7 @@ async fn queued_bare_rename_drains_next_input_after_name_update() { chat.handle_codex_event(Event { id: "rename".into(), - msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent { + msg: EventMsg::ThreadNameUpdated(crate::chatwidget::test_events::ThreadNameUpdatedEvent { thread_id, thread_name: Some("Queued rename".to_string()), }), @@ -536,7 +530,7 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() { chat.handle_codex_event(Event { id: "rename".into(), - msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent { + msg: EventMsg::ThreadNameUpdated(crate::chatwidget::test_events::ThreadNameUpdatedEvent { thread_id, thread_name: Some("Queued rename".to_string()), }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index c71c57dfef..18bb08e9b8 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -279,8 +279,8 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: 80.0, - window_minutes: Some(60), + used_percent: 80, + window_duration_mins: Some(60), resets_at: Some(123), }), secondary: None, @@ -314,13 +314,13 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: 10.0, - window_minutes: Some(60), + used_percent: 10, + window_duration_mins: Some(60), resets_at: None, }), secondary: Some(RateLimitWindow { - used_percent: 5.0, - window_minutes: Some(300), + used_percent: 5, + window_duration_mins: Some(300), resets_at: None, }), credits: None, @@ -333,13 +333,13 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: 25.0, - window_minutes: Some(30), + used_percent: 25, + window_duration_mins: Some(30), resets_at: Some(123), }), secondary: Some(RateLimitWindow { - used_percent: 15.0, - window_minutes: Some(300), + used_percent: 15, + window_duration_mins: Some(300), resets_at: Some(234), }), credits: None, @@ -352,13 +352,13 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: 30.0, - window_minutes: Some(60), + used_percent: 30, + window_duration_mins: Some(60), resets_at: Some(456), }), secondary: Some(RateLimitWindow { - used_percent: 18.0, - window_minutes: Some(300), + used_percent: 18, + window_duration_mins: Some(300), resets_at: Some(567), }), credits: None, @@ -376,8 +376,8 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { limit_id: Some("codex".to_string()), limit_name: Some("codex".to_string()), primary: Some(RateLimitWindow { - used_percent: 20.0, - window_minutes: Some(300), + used_percent: 20, + window_duration_mins: Some(300), resets_at: Some(100), }), secondary: None, @@ -394,8 +394,8 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { limit_id: Some("codex_other".to_string()), limit_name: Some("codex_other".to_string()), primary: Some(RateLimitWindow { - used_percent: 90.0, - window_minutes: Some(60), + used_percent: 90, + window_duration_mins: Some(60), resets_at: Some(200), }), secondary: None, @@ -447,8 +447,8 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() { limit_id: Some("codex_other".to_string()), limit_name: Some("codex_other".to_string()), primary: Some(RateLimitWindow { - used_percent: 95.0, - window_minutes: Some(60), + used_percent: 95, + window_duration_mins: Some(60), resets_at: None, }), secondary: None, @@ -1129,7 +1129,7 @@ async fn ui_snapshots_small_heights_task_running() { // task (status indicator active) while an approval request is shown. #[tokio::test] async fn status_widget_and_approval_modal_snapshot() { - use codex_protocol::protocol::ExecApprovalRequestEvent; + use crate::approval_events::ExecApprovalRequestEvent; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; // Begin a running task so the status indicator would be active. @@ -1421,7 +1421,7 @@ async fn status_line_branch_refreshes_after_interrupt() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, completed_at: None, @@ -1592,7 +1592,7 @@ async fn renamed_thread_footer_title_snapshot() { chat.thread_id = Some(thread_id); chat.handle_codex_event(Event { id: "rename".to_string(), - msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent { + msg: EventMsg::ThreadNameUpdated(crate::chatwidget::test_events::ThreadNameUpdatedEvent { thread_id, thread_name: Some("Roadmap cleanup".to_string()), }), @@ -2223,7 +2223,7 @@ async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() { #[tokio::test] async fn pre_tool_use_hook_events_render_snapshot() { assert_hook_events_snapshot( - codex_protocol::protocol::HookEventName::PreToolUse, + codex_app_server_protocol::HookEventName::PreToolUse, "pre-tool-use:0:/tmp/hooks.json", "warming the shell", "pre_tool_use_hook_events_render_snapshot", @@ -2234,7 +2234,7 @@ async fn pre_tool_use_hook_events_render_snapshot() { #[tokio::test] async fn post_tool_use_hook_events_render_snapshot() { assert_hook_events_snapshot( - codex_protocol::protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookEventName::PostToolUse, "post-tool-use:0:/tmp/hooks.json", "warming the shell", "post_tool_use_hook_events_render_snapshot", @@ -2248,18 +2248,18 @@ async fn completed_hook_with_no_entries_stays_out_of_history() { chat.handle_codex_event(Event { id: "hook-1".into(), - msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { + msg: EventMsg::HookStarted(crate::tool_activity::HookStartedEvent { turn_id: None, - run: codex_protocol::protocol::HookRunSummary { + run: codex_app_server_protocol::HookRunSummary { id: "post-tool-use:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::PostToolUse, - handler_type: codex_protocol::protocol::HookHandlerType::Command, - execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Turn, + event_name: codex_app_server_protocol::HookEventName::PostToolUse, + handler_type: codex_app_server_protocol::HookHandlerType::Command, + execution_mode: codex_app_server_protocol::HookExecutionMode::Sync, + scope: codex_app_server_protocol::HookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), - source: codex_protocol::protocol::HookSource::User, + source: codex_app_server_protocol::HookSource::User, display_order: 0, - status: codex_protocol::protocol::HookRunStatus::Running, + status: codex_app_server_protocol::HookRunStatus::Running, status_message: None, started_at: 1, completed_at: None, @@ -2274,18 +2274,18 @@ async fn completed_hook_with_no_entries_stays_out_of_history() { chat.handle_codex_event(Event { id: "hook-1".into(), - msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { + msg: EventMsg::HookCompleted(crate::tool_activity::HookCompletedEvent { turn_id: None, - run: codex_protocol::protocol::HookRunSummary { + run: codex_app_server_protocol::HookRunSummary { id: "post-tool-use:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::PostToolUse, - handler_type: codex_protocol::protocol::HookHandlerType::Command, - execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Turn, + event_name: codex_app_server_protocol::HookEventName::PostToolUse, + handler_type: codex_app_server_protocol::HookHandlerType::Command, + execution_mode: codex_app_server_protocol::HookExecutionMode::Sync, + scope: codex_app_server_protocol::HookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), - source: codex_protocol::protocol::HookSource::User, + source: codex_app_server_protocol::HookSource::User, display_order: 0, - status: codex_protocol::protocol::HookRunStatus::Completed, + status: codex_app_server_protocol::HookRunStatus::Completed, status_message: None, started_at: 1, completed_at: Some(2), @@ -2312,7 +2312,7 @@ async fn quiet_hook_linger_starts_when_delayed_redraw_reveals_hook() { chat.handle_codex_event(hook_started_event( "post-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookEventName::PostToolUse, Some("checking output policy"), )); assert!(drain_insert_history(&mut rx).is_empty()); @@ -2320,8 +2320,8 @@ async fn quiet_hook_linger_starts_when_delayed_redraw_reveals_hook() { reveal_running_hooks_after_delayed_redraw(&mut chat); chat.handle_codex_event(hook_completed_event( "post-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, - codex_protocol::protocol::HookRunStatus::Completed, + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Completed, Vec::new(), )); @@ -2340,19 +2340,19 @@ async fn blocked_and_failed_hooks_render_feedback_and_errors() { chat.handle_codex_event(hook_completed_event( "pre-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PreToolUse, - codex_protocol::protocol::HookRunStatus::Blocked, - vec![codex_protocol::protocol::HookOutputEntry { - kind: codex_protocol::protocol::HookOutputEntryKind::Feedback, + codex_app_server_protocol::HookEventName::PreToolUse, + codex_app_server_protocol::HookRunStatus::Blocked, + vec![codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Feedback, text: "run tests before touching the fixture".to_string(), }], )); chat.handle_codex_event(hook_completed_event( "post-tool-use:1:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, - codex_protocol::protocol::HookRunStatus::Failed, - vec![codex_protocol::protocol::HookOutputEntry { - kind: codex_protocol::protocol::HookOutputEntryKind::Error, + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Failed, + vec![codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Error, text: "hook exited with code 7".to_string(), }], )); @@ -2380,7 +2380,7 @@ async fn completed_hook_with_output_flushes_immediately() { chat.handle_codex_event(hook_started_event( "pre-tool-use:0:/tmp/hooks.json:tool-call-1", - codex_protocol::protocol::HookEventName::PreToolUse, + codex_app_server_protocol::HookEventName::PreToolUse, Some("checking command"), )); reveal_running_hooks(&mut chat); @@ -2388,10 +2388,10 @@ async fn completed_hook_with_output_flushes_immediately() { chat.handle_codex_event(hook_completed_event( "pre-tool-use:0:/tmp/hooks.json:tool-call-1", - codex_protocol::protocol::HookEventName::PreToolUse, - codex_protocol::protocol::HookRunStatus::Blocked, - vec![codex_protocol::protocol::HookOutputEntry { - kind: codex_protocol::protocol::HookOutputEntryKind::Feedback, + codex_app_server_protocol::HookEventName::PreToolUse, + codex_app_server_protocol::HookRunStatus::Blocked, + vec![codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Feedback, text: "command blocked by policy".to_string(), }], )); @@ -2413,17 +2413,17 @@ async fn completed_hook_output_precedes_following_assistant_message() { chat.handle_codex_event(hook_started_event( "pre-tool-use:0:/tmp/hooks.json:tool-call-1", - codex_protocol::protocol::HookEventName::PreToolUse, + codex_app_server_protocol::HookEventName::PreToolUse, Some("checking command"), )); reveal_running_hooks(&mut chat); chat.handle_codex_event(hook_completed_event( "pre-tool-use:0:/tmp/hooks.json:tool-call-1", - codex_protocol::protocol::HookEventName::PreToolUse, - codex_protocol::protocol::HookRunStatus::Blocked, - vec![codex_protocol::protocol::HookOutputEntry { - kind: codex_protocol::protocol::HookOutputEntryKind::Feedback, + codex_app_server_protocol::HookEventName::PreToolUse, + codex_app_server_protocol::HookRunStatus::Blocked, + vec![codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Feedback, text: "command blocked by policy".to_string(), }], )); @@ -2465,22 +2465,22 @@ async fn completed_same_id_hook_output_survives_restart() { chat.handle_codex_event(hook_started_event( hook_id, - codex_protocol::protocol::HookEventName::Stop, + codex_app_server_protocol::HookEventName::Stop, Some("checking stop condition"), )); reveal_running_hooks(&mut chat); chat.handle_codex_event(hook_completed_event( hook_id, - codex_protocol::protocol::HookEventName::Stop, - codex_protocol::protocol::HookRunStatus::Stopped, - vec![codex_protocol::protocol::HookOutputEntry { - kind: codex_protocol::protocol::HookOutputEntryKind::Stop, + codex_app_server_protocol::HookEventName::Stop, + codex_app_server_protocol::HookRunStatus::Stopped, + vec![codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Stop, text: "continue with more context".to_string(), }], )); chat.handle_codex_event(hook_started_event( hook_id, - codex_protocol::protocol::HookEventName::Stop, + codex_app_server_protocol::HookEventName::Stop, Some("checking stop condition"), )); reveal_running_hooks(&mut chat); @@ -2509,7 +2509,7 @@ async fn identical_parallel_running_hooks_collapse_to_count() { for tool_call_id in ["tool-call-1", "tool-call-2", "tool-call-3"] { chat.handle_codex_event(hook_started_event( &format!("pre-tool-use:0:/tmp/hooks.json:{tool_call_id}"), - codex_protocol::protocol::HookEventName::PreToolUse, + codex_app_server_protocol::HookEventName::PreToolUse, Some("checking command policy"), )); } @@ -2530,7 +2530,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() { chat.handle_codex_event(hook_started_event( "pre-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PreToolUse, + codex_app_server_protocol::HookEventName::PreToolUse, Some("checking command policy"), )); assert_eq!(chat.current_status.header, "Thinking"); @@ -2539,7 +2539,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() { chat.handle_codex_event(hook_started_event( "post-tool-use:1:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookEventName::PostToolUse, Some("checking output policy"), )); assert_eq!(chat.current_status.header, "Thinking"); @@ -2548,8 +2548,8 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() { chat.handle_codex_event(hook_completed_event( "pre-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PreToolUse, - codex_protocol::protocol::HookRunStatus::Completed, + codex_app_server_protocol::HookEventName::PreToolUse, + codex_app_server_protocol::HookRunStatus::Completed, Vec::new(), )); assert_eq!(chat.current_status.header, "Thinking"); @@ -2561,8 +2561,8 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() { chat.handle_codex_event(hook_completed_event( "post-tool-use:1:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, - codex_protocol::protocol::HookRunStatus::Completed, + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Completed, Vec::new(), )); assert_eq!(chat.current_status.header, "Thinking"); @@ -2589,7 +2589,7 @@ async fn running_hook_does_not_displace_active_exec_cell() { chat.handle_codex_event(hook_started_event( "post-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookEventName::PostToolUse, Some("checking output policy"), )); reveal_running_hooks(&mut chat); @@ -2608,8 +2608,8 @@ async fn running_hook_does_not_displace_active_exec_cell() { chat.handle_codex_event(hook_completed_event( "post-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, - codex_protocol::protocol::HookRunStatus::Completed, + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Completed, Vec::new(), )); assert!(drain_insert_history(&mut rx).is_empty()); @@ -2637,7 +2637,7 @@ async fn hidden_active_hook_does_not_add_transcript_separator() { chat.handle_codex_event(hook_started_event( "post-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookEventName::PostToolUse, Some("checking output policy"), )); let hidden_hook_transcript = chat @@ -2672,17 +2672,17 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash() chat.handle_codex_event(hook_started_event( "session-start:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::SessionStart, + codex_app_server_protocol::HookEventName::SessionStart, Some("warming the shell"), )); let started_hidden_snapshot = active_hook_blob(&chat); chat.handle_codex_event(hook_completed_event( "session-start:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::SessionStart, - codex_protocol::protocol::HookRunStatus::Completed, - vec![codex_protocol::protocol::HookOutputEntry { - kind: codex_protocol::protocol::HookOutputEntryKind::Context, + codex_app_server_protocol::HookEventName::SessionStart, + codex_app_server_protocol::HookRunStatus::Completed, + vec![codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Context, text: "session context".to_string(), }], )); @@ -2700,7 +2700,7 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash() #[tokio::test] async fn session_start_hook_events_render_snapshot() { assert_hook_events_snapshot( - codex_protocol::protocol::HookEventName::SessionStart, + codex_app_server_protocol::HookEventName::SessionStart, "session-start:0:/tmp/hooks.json", "warming the shell", "session_start_hook_events_render_snapshot", @@ -2710,17 +2710,17 @@ async fn session_start_hook_events_render_snapshot() { fn hook_started_event( id: &str, - event_name: codex_protocol::protocol::HookEventName, + event_name: codex_app_server_protocol::HookEventName, status_message: Option<&str>, ) -> Event { Event { id: id.to_string(), - msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { + msg: EventMsg::HookStarted(crate::tool_activity::HookStartedEvent { turn_id: None, run: hook_run_summary( id, event_name, - codex_protocol::protocol::HookRunStatus::Running, + codex_app_server_protocol::HookRunStatus::Running, status_message, Vec::new(), ), @@ -2730,13 +2730,13 @@ fn hook_started_event( fn hook_completed_event( id: &str, - event_name: codex_protocol::protocol::HookEventName, - status: codex_protocol::protocol::HookRunStatus, - entries: Vec, + event_name: codex_app_server_protocol::HookEventName, + status: codex_app_server_protocol::HookRunStatus, + entries: Vec, ) -> Event { Event { id: id.to_string(), - msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { + msg: EventMsg::HookCompleted(crate::tool_activity::HookCompletedEvent { turn_id: None, run: hook_run_summary( id, event_name, status, /*status_message*/ None, entries, @@ -2747,25 +2747,25 @@ fn hook_completed_event( fn hook_run_summary( id: &str, - event_name: codex_protocol::protocol::HookEventName, - status: codex_protocol::protocol::HookRunStatus, + event_name: codex_app_server_protocol::HookEventName, + status: codex_app_server_protocol::HookRunStatus, status_message: Option<&str>, - entries: Vec, -) -> codex_protocol::protocol::HookRunSummary { - codex_protocol::protocol::HookRunSummary { + entries: Vec, +) -> codex_app_server_protocol::HookRunSummary { + codex_app_server_protocol::HookRunSummary { id: id.to_string(), event_name, - handler_type: codex_protocol::protocol::HookHandlerType::Command, - execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Turn, + handler_type: codex_app_server_protocol::HookHandlerType::Command, + execution_mode: codex_app_server_protocol::HookExecutionMode::Sync, + scope: codex_app_server_protocol::HookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), - source: codex_protocol::protocol::HookSource::User, + source: codex_app_server_protocol::HookSource::User, display_order: 0, status, status_message: status_message.map(str::to_string), started_at: 1, - completed_at: (status != codex_protocol::protocol::HookRunStatus::Running).then_some(2), - duration_ms: (status != codex_protocol::protocol::HookRunStatus::Running).then_some(1), + completed_at: (status != codex_app_server_protocol::HookRunStatus::Running).then_some(2), + duration_ms: (status != codex_app_server_protocol::HookRunStatus::Running).then_some(1), entries, } } diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs new file mode 100644 index 0000000000..8e5d539039 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -0,0 +1,135 @@ +//! User-message display models and helpers for the chat widget. +//! +//! App-server turn items and queued TUI submissions describe user input in +//! slightly different shapes. This module keeps the display-only representation +//! and comparison keys together so chat rendering can avoid duplicate user rows +//! without depending on core protocol event wrappers. + +use std::path::PathBuf; + +use codex_protocol::items::UserMessageItem; +use codex_protocol::user_input::TextElement; +use codex_protocol::user_input::UserInput; + +use super::ChatWidget; +use super::append_text_with_rebased_elements; + +#[derive(Debug, Clone)] +pub(crate) struct UserMessageEvent { + pub(super) message: String, + pub(super) images: Option>, + pub(super) local_images: Vec, + pub(super) text_elements: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct RenderedUserMessageEvent { + pub(super) message: String, + pub(super) remote_image_urls: Vec, + pub(super) local_images: Vec, + pub(super) text_elements: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PendingSteerCompareKey { + pub(super) message: String, + pub(super) image_count: usize, +} + +impl ChatWidget { + pub(super) fn rendered_user_message_event_from_parts( + message: String, + text_elements: Vec, + local_images: Vec, + remote_image_urls: Vec, + ) -> RenderedUserMessageEvent { + RenderedUserMessageEvent { + message, + remote_image_urls, + local_images, + text_elements, + } + } + + pub(super) fn rendered_user_message_event_from_event( + event: &UserMessageEvent, + ) -> RenderedUserMessageEvent { + Self::rendered_user_message_event_from_parts( + event.message.clone(), + event.text_elements.clone(), + event.local_images.clone(), + event.images.clone().unwrap_or_default(), + ) + } + + /// Build the compare key for a submitted pending steer without invoking the + /// expensive request-serialization path. Pending steers only need to match the + /// committed `ItemCompleted(UserMessage)` emitted after core drains input, which + /// preserves flattened text and total image count but not UI-only text ranges or + /// local image paths. + pub(super) fn pending_steer_compare_key_from_items( + items: &[UserInput], + ) -> PendingSteerCompareKey { + let mut message = String::new(); + let mut image_count = 0; + + for item in items { + match item { + UserInput::Text { text, .. } => message.push_str(text), + UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1, + UserInput::Skill { .. } | UserInput::Mention { .. } => {} + _ => {} + } + } + + PendingSteerCompareKey { + message, + image_count, + } + } + + pub(super) fn pending_steer_compare_key_from_item( + item: &UserMessageItem, + ) -> PendingSteerCompareKey { + Self::pending_steer_compare_key_from_items(&item.content) + } + + pub(super) fn rendered_user_message_event_from_inputs( + items: &[UserInput], + ) -> RenderedUserMessageEvent { + let mut message = String::new(); + let mut remote_image_urls = Vec::new(); + let mut local_images = Vec::new(); + let mut text_elements = Vec::new(); + + for item in items { + match item { + UserInput::Text { + text, + text_elements: current_text_elements, + } => append_text_with_rebased_elements( + &mut message, + &mut text_elements, + text, + current_text_elements.iter().map(|element| { + TextElement::new( + element.byte_range, + element.placeholder(text).map(str::to_string), + ) + }), + ), + UserInput::Image { image_url } => remote_image_urls.push(image_url.clone()), + UserInput::LocalImage { path } => local_images.push(path.clone()), + UserInput::Skill { .. } | UserInput::Mention { .. } => {} + _ => {} + } + } + + Self::rendered_user_message_event_from_parts( + message, + text_elements, + local_images, + remote_image_urls, + ) + } +} diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index dde85d9392..5922384f3b 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -1,5 +1,6 @@ use crate::history_cell::PlainHistoryCell; use crate::legacy_core::config::Config; +use crate::session_state::SessionNetworkProxyRuntime; use codex_app_server_protocol::ConfigLayerSource; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerStack; @@ -12,7 +13,6 @@ use codex_config::RequirementSource; use codex_config::ResidencyRequirement; use codex_config::SandboxModeRequirement; use codex_config::WebSearchModeRequirement; -use codex_protocol::protocol::SessionNetworkProxyRuntime; use ratatui::style::Stylize; use ratatui::text::Line; use toml::Value as TomlValue; @@ -505,6 +505,7 @@ mod tests { use super::render_debug_config_lines; use super::session_all_proxy_url; use crate::legacy_core::config::Constrained; + use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigLayerSource; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerStack; @@ -532,7 +533,6 @@ mod tests { use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::PermissionProfile; - use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::text::Line; use std::collections::BTreeMap; @@ -615,7 +615,7 @@ mod tests { let requirements = ConfigRequirements { approval_policy: ConstrainedWithSource::new( - Constrained::allow_any(AskForApproval::OnRequest), + Constrained::allow_any(AskForApproval::OnRequest.to_core()), Some(RequirementSource::CloudRequirements), ), approvals_reviewer: ConstrainedWithSource::new( @@ -679,7 +679,7 @@ mod tests { }; let requirements_toml = ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + allowed_approval_policies: Some(vec![AskForApproval::OnRequest.to_core()]), allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), remote_sandbox_config: None, diff --git a/codex-rs/tui/src/diff_model.rs b/codex-rs/tui/src/diff_model.rs new file mode 100644 index 0000000000..3b7055ea17 --- /dev/null +++ b/codex-rs/tui/src/diff_model.rs @@ -0,0 +1,21 @@ +//! Minimal file-change model used by TUI diff rendering and approval previews. + +use std::path::PathBuf; + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum FileChange { + Add { + content: String, + }, + Delete { + content: String, + }, + Update { + unified_diff: String, + move_path: Option, + }, +} diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index dc420a7d7e..350c3c7aa0 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -77,6 +77,7 @@ const LIGHT_256_GUTTER_FG_IDX: u8 = 236; use crate::color::is_light; use crate::color::perceptual_distance; +use crate::diff_model::FileChange; use crate::exec_command::relativize_to_home; use crate::render::Insets; use crate::render::highlight::DiffScopeBackgroundRgbs; @@ -94,7 +95,6 @@ use crate::terminal_palette::indexed_color; use crate::terminal_palette::rgb_color; use crate::terminal_palette::stdout_color_level; use codex_git_utils::get_git_repo_root; -use codex_protocol::protocol::FileChange; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; @@ -299,7 +299,7 @@ pub struct DiffSummary { } impl DiffSummary { - pub fn new(changes: HashMap, cwd: AbsolutePathBuf) -> Self { + pub(crate) fn new(changes: HashMap, cwd: AbsolutePathBuf) -> Self { Self { changes, cwd } } } diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 878d42c711..cd3f56166c 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -8,8 +8,8 @@ use std::time::Duration; use std::time::Instant; +use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; use codex_protocol::parse_command::ParsedCommand; -use codex_protocol::protocol::ExecCommandSource; #[derive(Clone, Debug, Default)] pub(crate) struct CommandOutput { diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 423217a6f6..7c1b533ac6 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -13,8 +13,8 @@ use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::adaptive_wrap_lines; use codex_ansi_escape::ansi_escape_line; +use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; use codex_protocol::parse_command::ParsedCommand; -use codex_protocol::protocol::ExecCommandSource; use codex_shell_command::bash::extract_bash_command; use codex_utils_elapsed::format_duration; use itertools::Itertools; @@ -713,7 +713,7 @@ const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( #[cfg(test)] mod tests { use super::*; - use codex_protocol::protocol::ExecCommandSource; + use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; use pretty_assertions::assert_eq; fn render_line_text(line: &Line<'static>) -> String { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 9d009a93fb..995c70121d 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -10,6 +10,7 @@ //! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the //! rendered transcript output can change. +use crate::diff_model::FileChange; use crate::diff_render::create_diff_summary; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; @@ -27,6 +28,7 @@ use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; use crate::render::renderable::Renderable; +use crate::session_state::ThreadSessionState; use crate::style::proposed_plan_style; use crate::style::user_message_style; #[cfg(test)] @@ -35,6 +37,7 @@ use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; +use crate::tool_activity::McpInvocation; use crate::tooltips; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::update_action::UpdateAction; @@ -43,8 +46,13 @@ use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::adaptive_wrap_lines; use base64::Engine; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::McpAuthStatus; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::McpServerStatusDetail; +use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_config::types::McpServerTransportConfig; #[cfg(test)] use codex_mcp::qualified_mcp_tool_name_prefix; @@ -54,7 +62,6 @@ use codex_protocol::account::PlanType; use codex_protocol::mcp::Resource; #[cfg(test)] use codex_protocol::mcp::ResourceTemplate; -use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::WebSearchAction; use codex_protocol::models::local_image_label_text; @@ -62,11 +69,6 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::McpAuthStatus; -use codex_protocol::protocol::McpInvocation; -use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::user_input::TextElement; @@ -862,11 +864,11 @@ fn exec_snippet(command: &[String]) -> String { pub fn new_approval_decision_cell( command: Vec, - decision: codex_protocol::protocol::ReviewDecision, + decision: crate::approval_display::ReviewDecision, actor: ApprovalDecisionActor, ) -> Box { - use codex_protocol::protocol::NetworkPolicyRuleAction; - use codex_protocol::protocol::ReviewDecision::*; + use crate::approval_display::ReviewDecision::*; + use codex_protocol::approvals::NetworkPolicyRuleAction; let (symbol, summary): (Span<'static>, Vec>) = match decision { Approved => { @@ -1231,28 +1233,24 @@ impl HistoryCell for SessionInfoCell { pub(crate) fn new_session_info( config: &Config, requested_model: &str, - event: SessionConfiguredEvent, + session: &ThreadSessionState, is_first_event: bool, tooltip_override: Option, auth_plan: Option, show_fast_status: bool, ) -> SessionInfoCell { - let SessionConfiguredEvent { - model, - reasoning_effort, - approval_policy, - permission_profile, - .. - } = event; // Header box rendered as history (so it appears at the very top) let header = SessionHeaderHistoryCell::new( - model.clone(), - reasoning_effort, + session.model.clone(), + session.reasoning_effort, show_fast_status, config.cwd.to_path_buf(), CODEX_CLI_VERSION, ) - .with_yolo_mode(has_yolo_permissions(approval_policy, &permission_profile)); + .with_yolo_mode(has_yolo_permissions( + session.approval_policy, + &session.permission_profile, + )); let mut parts: Vec> = vec![Box::new(header)]; if is_first_event { @@ -1298,11 +1296,11 @@ pub(crate) fn new_session_info( { parts.push(Box::new(tooltips)); } - if requested_model != model { + if requested_model != session.model.as_str() { let lines = vec![ "model changed:".magenta().bold().into(), format!("requested: {requested_model}").into(), - format!("used: {model}").into(), + format!("used: {}", session.model).into(), ]; parts.push(Box::new(PlainHistoryCell { lines })); } @@ -1313,7 +1311,7 @@ pub(crate) fn new_session_info( pub(crate) fn is_yolo_mode(config: &Config) -> bool { has_yolo_permissions( - config.permissions.approval_policy.value(), + AskForApproval::from(config.permissions.approval_policy.value()), &config.permissions.permission_profile(), ) } @@ -1322,17 +1320,27 @@ fn has_yolo_permissions( approval_policy: AskForApproval, permission_profile: &PermissionProfile, ) -> bool { + let permission_profile = AppServerPermissionProfile::from(permission_profile.clone()); approval_policy == AskForApproval::Never && matches!( permission_profile, - PermissionProfile::Disabled - | PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Unrestricted, - network: codex_protocol::protocol::NetworkSandboxPolicy::Enabled, + AppServerPermissionProfile::Disabled + | AppServerPermissionProfile::Managed { + file_system: PermissionProfileFileSystemPermissions::Unrestricted, + network: PermissionProfileNetworkPermissions { enabled: true }, } ) } +fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str { + match status { + McpAuthStatus::Unsupported => "Unsupported", + McpAuthStatus::NotLoggedIn => "Not logged in", + McpAuthStatus::BearerToken => "Bearer token", + McpAuthStatus::OAuth => "OAuth", + } +} + pub(crate) fn new_user_prompt( message: String, text_elements: Vec, @@ -2041,7 +2049,13 @@ pub(crate) fn new_mcp_tools_output( } lines.push(header.into()); lines.push(vec![" • Status: ".into(), "enabled".green()].into()); - lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into()); + lines.push( + vec![ + " • Auth: ".into(), + mcp_auth_status_label(auth_status).into(), + ] + .into(), + ); match &cfg.transport { McpServerTransportConfig::Stdio { @@ -2219,7 +2233,13 @@ pub(crate) fn new_mcp_tools_output_from_statuses( codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth, }) .unwrap_or(McpAuthStatus::Unsupported); - lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into()); + lines.push( + vec![ + " • Auth: ".into(), + mcp_auth_status_label(auth_status).into(), + ] + .into(), + ); if let Some(cfg) = cfg { match &cfg.transport { @@ -2992,7 +3012,10 @@ mod tests { use crate::exec_cell::ExecCell; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; + use crate::session_state::ThreadSessionState; use crate::wrapping::word_wrap_lines; + use codex_app_server_protocol::AskForApproval; + use codex_app_server_protocol::McpAuthStatus; use codex_config::types::McpServerConfig; use codex_config::types::McpServerDisabledReason; use codex_otel::RuntimeMetricTotals; @@ -3001,9 +3024,6 @@ mod tests { use codex_protocol::account::PlanType; use codex_protocol::models::WebSearchAction; use codex_protocol::parse_command::ParsedCommand; - use codex_protocol::protocol::AskForApproval; - use codex_protocol::protocol::McpAuthStatus; - use codex_protocol::protocol::SessionConfiguredEvent; use dirs::home_dir; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; @@ -3012,9 +3032,9 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; + use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::Tool; - use codex_protocol::protocol::ExecCommandSource; use rmcp::model::Content; const SMALL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; @@ -3183,10 +3203,11 @@ mod tests { ); } - fn session_configured_event(model: &str) -> SessionConfiguredEvent { - SessionConfiguredEvent { - session_id: ThreadId::new(), + fn session_configured_event(model: &str) -> ThreadSessionState { + ThreadSessionState { + thread_id: ThreadId::new(), forked_from_id: None, + fork_parent_title: None, thread_name: None, model: model.to_string(), model_provider_id: "test-provider".to_string(), @@ -3195,10 +3216,10 @@ mod tests { approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), cwd: test_path_buf("/tmp/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: None, history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } @@ -3296,7 +3317,7 @@ mod tests { let cell = new_session_info( &config, "gpt-5", - session_configured_event("gpt-5"), + &session_configured_event("gpt-5"), /*is_first_event*/ false, Some("Model just became available".to_string()), Some(PlanType::Free), @@ -3318,7 +3339,7 @@ mod tests { let cell = new_session_info( &config, "gpt-5", - session_configured_event("gpt-5"), + &session_configured_event("gpt-5"), /*is_first_event*/ false, Some("Model just became available".to_string()), Some(PlanType::Free), @@ -3335,7 +3356,7 @@ mod tests { let cell = new_session_info( &config, "gpt-5", - session_configured_event("gpt-5"), + &session_configured_event("gpt-5"), /*is_first_event*/ true, Some("Model just became available".to_string()), Some(PlanType::Free), @@ -3354,7 +3375,7 @@ mod tests { let cell = new_session_info( &config, "gpt-5", - session_configured_event("gpt-5"), + &session_configured_event("gpt-5"), /*is_first_event*/ false, Some("Model just became available".to_string()), Some(PlanType::Free), @@ -4215,10 +4236,11 @@ mod tests { #[test] fn yolo_mode_includes_managed_full_access_profiles() { - let permission_profile = PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Unrestricted, - network: codex_protocol::protocol::NetworkSandboxPolicy::Enabled, - }; + let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: true }, + file_system: PermissionProfileFileSystemPermissions::Unrestricted, + } + .into(); assert!(has_yolo_permissions( AskForApproval::Never, @@ -4228,9 +4250,10 @@ mod tests { #[test] fn yolo_mode_excludes_external_sandbox_profiles() { - let permission_profile = PermissionProfile::External { - network: codex_protocol::protocol::NetworkSandboxPolicy::Enabled, - }; + let permission_profile: PermissionProfile = AppServerPermissionProfile::External { + network: PermissionProfileNetworkPermissions { enabled: true }, + } + .into(); assert!(!has_yolo_permissions( AskForApproval::Never, diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index 138a17b5a8..c44d353c4c 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -14,11 +14,11 @@ use super::HistoryCell; use crate::exec_cell::spinner; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; -use codex_protocol::protocol::HookEventName; -use codex_protocol::protocol::HookOutputEntry; -use codex_protocol::protocol::HookOutputEntryKind; -use codex_protocol::protocol::HookRunStatus; -use codex_protocol::protocol::HookRunSummary; +use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::HookOutputEntry; +use codex_app_server_protocol::HookOutputEntryKind; +use codex_app_server_protocol::HookRunStatus; +use codex_app_server_protocol::HookRunSummary; use ratatui::prelude::*; use ratatui::style::Stylize; use ratatui::widgets::Paragraph; @@ -765,11 +765,11 @@ mod tests { HookRunSummary { id: id.to_string(), event_name: HookEventName::PostToolUse, - handler_type: codex_protocol::protocol::HookHandlerType::Command, - execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Turn, + handler_type: codex_app_server_protocol::HookHandlerType::Command, + execution_mode: codex_app_server_protocol::HookExecutionMode::Sync, + scope: codex_app_server_protocol::HookScope::Turn, source_path: test_path_buf("/tmp/hooks.json").abs(), - source: codex_protocol::protocol::HookSource::User, + source: codex_app_server_protocol::HookSource::User, display_order: 0, status: HookRunStatus::Running, status_message: Some("checking output policy".to_string()), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 39b4294b4a..2037d152ca 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -12,6 +12,8 @@ use crate::legacy_core::config::load_config_as_toml_with_cli_overrides; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::format_exec_policy_error_with_source; use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; +use crate::session_resume::ResolveCwdOutcome; +use crate::session_resume::resolve_cwd_for_resume_or_fork; use additional_dirs::add_dir_warning_message; use app::App; pub use app::AppExitInfo; @@ -24,6 +26,7 @@ use codex_app_server_client::InProcessClientStartArgs; use codex_app_server_client::RemoteAppServerClient; use codex_app_server_client::RemoteAppServerConnectArgs; use codex_app_server_protocol::Account as AppServerAccount; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode as AppServerAuthMode; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::Thread as AppServerThread; @@ -46,11 +49,6 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::RolloutLine; -use codex_protocol::protocol::TurnContextItem; -use codex_rollout::read_session_meta_line; use codex_rollout::state_db::get_state_db; use codex_state::log_db; use codex_terminal_detection::terminal_info; @@ -58,16 +56,17 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use codex_utils_oss::ensure_oss_provider_ready; use codex_utils_oss::get_default_model_for_oss_provider; -use codex_utils_path as path_utils; use color_eyre::eyre::WrapErr; use cwd_prompt::CwdPromptAction; -use cwd_prompt::CwdPromptOutcome; -use cwd_prompt::CwdSelection; use std::fs::OpenOptions; use std::future::Future; use std::path::Path; use std::path::PathBuf; +#[cfg(test)] +use std::pin::Pin; use std::sync::Arc; +pub use token_usage::FinalOutput; +pub use token_usage::TokenUsage; use tracing::Level; use tracing::error; use tracing::warn; @@ -88,6 +87,8 @@ mod app_event; mod app_event_sender; mod app_server_approval_conversions; mod app_server_session; +mod approval_display; +mod approval_events; mod ascii_animation; #[cfg(not(target_os = "linux"))] mod audio_device; @@ -117,6 +118,7 @@ pub use custom_terminal::Terminal; mod auto_review_denials; mod cwd_prompt; mod debug_config; +mod diff_model; mod diff_render; mod exec_cell; mod exec_command; @@ -157,6 +159,8 @@ mod resize_reflow_cap; mod resume_picker; mod selection_list; mod session_log; +mod session_resume; +mod session_state; mod shimmer; mod skills_helpers; mod slash_command; @@ -168,9 +172,12 @@ mod terminal_palette; mod terminal_title; mod text_formatting; mod theme_picker; +mod token_usage; +mod tool_activity; mod tooltips; mod transcript_reflow; mod tui; +mod turn_state; mod ui_consts; pub(crate) mod update_action; pub use update_action::UpdateAction; @@ -189,7 +196,7 @@ mod width; mod voice { use crate::app_event_sender::AppEventSender; use crate::legacy_core::config::Config; - use codex_protocol::protocol::RealtimeAudioFrame; + use codex_app_server_protocol::ThreadRealtimeAudioChunk; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU16; @@ -231,7 +238,10 @@ mod voice { Err("voice output is unavailable in this build".to_string()) } - pub(crate) fn enqueue_frame(&self, _frame: &RealtimeAudioFrame) -> Result<(), String> { + pub(crate) fn enqueue_frame( + &self, + _frame: &ThreadRealtimeAudioChunk, + ) -> Result<(), String> { Err("voice output is unavailable in this build".to_string()) } @@ -435,15 +445,17 @@ pub(crate) async fn start_app_server_for_picker( } #[cfg(test)] -pub(crate) async fn start_embedded_app_server_for_picker( +pub(crate) fn start_embedded_app_server_for_picker( config: &Config, -) -> color_eyre::Result { - start_app_server_for_picker( - config, - &AppServerTarget::Embedded, - Arc::new(EnvironmentManager::default_for_tests()), - ) - .await +) -> Pin> + '_>> { + Box::pin(async move { + start_app_server_for_picker( + config, + &AppServerTarget::Embedded, + Arc::new(EnvironmentManager::default_for_tests()), + ) + .await + }) } #[allow(clippy::too_many_arguments)] @@ -482,7 +494,8 @@ where log_db, environment_manager, config_warnings, - session_source: codex_protocol::protocol::SessionSource::Cli, + session_source: serde_json::from_value(serde_json::json!("cli")) + .unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")), enable_codex_api_key_env: false, client_name: "codex-tui".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), @@ -700,7 +713,7 @@ pub async fn run_main( let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox { ( Some(SandboxMode::DangerFullAccess), - Some(AskForApproval::Never), + Some(AskForApproval::Never.to_core()), ) } else { ( @@ -1078,7 +1091,7 @@ async fn run_ratatui_app( UpdatePromptOutcome::RunUpdate(action) => { terminal_restore_guard.restore()?; return Ok(AppExitInfo { - token_usage: codex_protocol::protocol::TokenUsage::default(), + token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, thread_name: None, update_action: Some(action), @@ -1155,7 +1168,7 @@ async fn run_ratatui_app( session_log::log_session_end(); let _ = tui.terminal.clear(); return Ok(AppExitInfo { - token_usage: codex_protocol::protocol::TokenUsage::default(), + token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, thread_name: None, update_action: None, @@ -1200,7 +1213,7 @@ async fn run_ratatui_app( session_log::log_session_end(); let _ = tui.terminal.clear(); Ok(AppExitInfo { - token_usage: codex_protocol::protocol::TokenUsage::default(), + token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, thread_name: None, update_action: None, @@ -1261,7 +1274,7 @@ async fn run_ratatui_app( terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Ok(AppExitInfo { - token_usage: codex_protocol::protocol::TokenUsage::default(), + token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, thread_name: None, update_action: None, @@ -1322,7 +1335,7 @@ async fn run_ratatui_app( terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Ok(AppExitInfo { - token_usage: codex_protocol::protocol::TokenUsage::default(), + token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, thread_name: None, update_action: None, @@ -1367,7 +1380,7 @@ async fn run_ratatui_app( terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Ok(AppExitInfo { - token_usage: codex_protocol::protocol::TokenUsage::default(), + token_usage: crate::token_usage::TokenUsage::default(), thread_id: None, thread_name: None, update_action: None, @@ -1472,123 +1485,6 @@ async fn run_ratatui_app( app_result } -pub(crate) async fn resolve_session_thread_id( - path: &Path, - id_str_if_uuid: Option<&str>, -) -> Option { - match id_str_if_uuid { - Some(id_str) => ThreadId::from_string(id_str).ok(), - None => read_session_meta_line(path) - .await - .ok() - .map(|meta_line| meta_line.meta.id), - } -} - -pub(crate) async fn read_session_cwd( - config: &Config, - thread_id: ThreadId, - path: Option<&Path>, -) -> Option { - if let Some(state_db_ctx) = get_state_db(config).await - && let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await - { - return Some(metadata.cwd); - } - - // Prefer the latest TurnContext cwd so resume/fork reflects the most recent - // session directory (for the changed-cwd prompt) when DB data is unavailable. - // The alternative would be mutating the SessionMeta line when the session cwd - // changes, but the rollout is an append-only JSONL log and rewriting the head - // would be error-prone. - let path = path?; - if let Some(cwd) = read_latest_turn_context(path).await.map(|item| item.cwd) { - return Some(cwd); - } - match read_session_meta_line(path).await { - Ok(meta_line) => Some(meta_line.meta.cwd), - Err(err) => { - let rollout_path = path.display().to_string(); - tracing::warn!( - %rollout_path, - %err, - "Failed to read session metadata from rollout" - ); - None - } - } -} - -pub(crate) async fn read_session_model( - config: &Config, - thread_id: ThreadId, - path: Option<&Path>, -) -> Option { - if let Some(state_db_ctx) = get_state_db(config).await - && let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await - && let Some(model) = metadata.model - { - return Some(model); - } - - let path = path?; - read_latest_turn_context(path).await.map(|item| item.model) -} - -async fn read_latest_turn_context(path: &Path) -> Option { - let text = tokio::fs::read_to_string(path).await.ok()?; - for line in text.lines().rev() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let Ok(rollout_line) = serde_json::from_str::(trimmed) else { - continue; - }; - if let RolloutItem::TurnContext(item) = rollout_line.item { - return Some(item); - } - } - None -} - -pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool { - !path_utils::paths_match_after_normalization(current_cwd, session_cwd) -} - -pub(crate) enum ResolveCwdOutcome { - Continue(Option), - Exit, -} - -pub(crate) async fn resolve_cwd_for_resume_or_fork( - tui: &mut Tui, - config: &Config, - current_cwd: &Path, - thread_id: ThreadId, - path: Option<&Path>, - action: CwdPromptAction, - allow_prompt: bool, -) -> color_eyre::Result { - let Some(history_cwd) = read_session_cwd(config, thread_id, path).await else { - return Ok(ResolveCwdOutcome::Continue(None)); - }; - if allow_prompt && cwds_differ(current_cwd, &history_cwd) { - let selection_outcome = - cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?; - return Ok(match selection_outcome { - CwdPromptOutcome::Selection(CwdSelection::Current) => { - ResolveCwdOutcome::Continue(Some(current_cwd.to_path_buf())) - } - CwdPromptOutcome::Selection(CwdSelection::Session) => { - ResolveCwdOutcome::Continue(Some(history_cwd)) - } - CwdPromptOutcome::Exit => ResolveCwdOutcome::Exit, - }); - } - Ok(ResolveCwdOutcome::Continue(Some(history_cwd))) -} - #[expect( clippy::print_stderr, reason = "TUI should no longer be displayed, so we can write to stderr." @@ -1762,23 +1658,29 @@ mod tests { use super::*; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; + use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_config::config_toml::ProjectConfig; - use codex_features::Feature; - use codex_protocol::protocol::AskForApproval; - use codex_protocol::protocol::RolloutItem; - use codex_protocol::protocol::RolloutLine; - use codex_protocol::protocol::SessionMeta; - use codex_protocol::protocol::SessionMetaLine; - use codex_protocol::protocol::SessionSource; - use codex_protocol::protocol::TurnContextItem; use pretty_assertions::assert_eq; use serial_test::serial; use tempfile::TempDir; + fn run_large_stack_test(body: impl FnOnce() -> T + Send + 'static) -> T + where + T: Send + 'static, + { + std::thread::Builder::new() + .name("codex-tui-lib-test".to_string()) + .stack_size(8 * 1024 * 1024) + .spawn(body) + .expect("spawn large-stack test thread") + .join() + .expect("large-stack test thread panicked") + } + async fn build_config(temp_dir: &TempDir) -> std::io::Result { ConfigBuilder::default() .codex_home(temp_dir.path().to_path_buf()) @@ -2031,17 +1933,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn read_session_cwd_returns_none_without_sqlite_or_rollout_path() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let config = build_config(&temp_dir).await?; - - let cwd = read_session_cwd(&config, ThreadId::new(), /*path*/ None).await; - - assert_eq!(cwd, None); - Ok(()) - } - #[tokio::test] #[serial] async fn windows_shows_trust_prompt_without_sandbox() -> std::io::Result<()> { @@ -2079,62 +1970,74 @@ mod tests { Ok(()) } - #[tokio::test] - async fn lookup_session_target_by_name_uses_backend_title_search() -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let config = build_config(&temp_dir).await?; - let thread_id = ThreadId::new(); - let rollout_path = temp_dir - .path() - .join("sessions/2025/02/01") - .join(format!("rollout-2025-02-01T10-00-00-{thread_id}.jsonl")); - let rollout_dir = rollout_path.parent().expect("rollout parent"); - std::fs::create_dir_all(rollout_dir)?; - std::fs::write(&rollout_path, "")?; + #[test] + fn lookup_session_target_by_name_uses_backend_title_search() -> color_eyre::Result<()> { + run_large_stack_test(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let thread_id = ThreadId::new(); + let rollout_path = temp_dir + .path() + .join("sessions/2025/02/01") + .join(format!("rollout-2025-02-01T10-00-00-{thread_id}.jsonl")); + let rollout_dir = rollout_path.parent().expect("rollout parent"); + std::fs::create_dir_all(rollout_dir)?; + std::fs::write(&rollout_path, "")?; - let state_runtime = codex_state::StateRuntime::init( - config.codex_home.to_path_buf(), - config.model_provider_id.clone(), - ) - .await - .map_err(std::io::Error::other)?; - state_runtime - .mark_backfill_complete(/*last_watermark*/ None) - .await - .map_err(std::io::Error::other)?; + let state_runtime = codex_state::StateRuntime::init( + config.codex_home.to_path_buf(), + config.model_provider_id.clone(), + ) + .await + .map_err(std::io::Error::other)?; + state_runtime + .mark_backfill_complete(/*last_watermark*/ None) + .await + .map_err(std::io::Error::other)?; - let session_cwd = temp_dir.path().join("project"); - std::fs::create_dir_all(&session_cwd)?; - let created_at = chrono::DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z") - .expect("timestamp should parse") - .with_timezone(&chrono::Utc); - let mut builder = codex_state::ThreadMetadataBuilder::new( - thread_id, - rollout_path.clone(), - created_at, - SessionSource::Cli, - ); - builder.cwd = session_cwd; - let mut metadata = builder.build(config.model_provider_id.as_str()); - metadata.title = "saved-session".to_string(); - metadata.first_user_message = Some("preview text".to_string()); - state_runtime - .upsert_thread(&metadata) - .await - .map_err(std::io::Error::other)?; + let session_cwd = temp_dir.path().join("project"); + std::fs::create_dir_all(&session_cwd)?; + let created_at = chrono::DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z") + .expect("timestamp should parse") + .with_timezone(&chrono::Utc); + let mut builder = codex_state::ThreadMetadataBuilder::new( + thread_id, + rollout_path.clone(), + created_at, + serde_json::from_value(serde_json::json!("cli")) + .expect("cli session source should deserialize"), + ); + builder.cwd = session_cwd; + let mut metadata = builder.build(config.model_provider_id.as_str()); + metadata.title = "saved-session".to_string(); + metadata.first_user_message = Some("preview text".to_string()); + state_runtime + .upsert_thread(&metadata) + .await + .map_err(std::io::Error::other)?; - let mut app_server = - AppServerSession::new(codex_app_server_client::AppServerClient::InProcess( - start_test_embedded_app_server(config).await?, - )); - let target = - lookup_session_target_by_name_with_app_server(&mut app_server, "saved-session").await?; - let target = target.expect("name lookup should find the saved thread"); - assert_eq!(target.path, Some(rollout_path)); - assert_eq!(target.thread_id, thread_id); + let mut app_server = + AppServerSession::new(codex_app_server_client::AppServerClient::InProcess( + start_test_embedded_app_server(config).await?, + )); + let target = lookup_session_target_by_name_with_app_server( + &mut app_server, + "saved-session", + ) + .await?; + let target = target.expect("name lookup should find the saved thread"); + assert_eq!(target.path, Some(rollout_path)); + assert_eq!(target.thread_id, thread_id); - app_server.shutdown().await?; - Ok(()) + app_server.shutdown().await?; + Ok(()) + }) + }) } #[tokio::test] @@ -2204,118 +2107,6 @@ mod tests { Ok(()) } - fn build_turn_context(config: &Config, cwd: PathBuf) -> TurnContextItem { - let model = config - .model - .clone() - .unwrap_or_else(|| "gpt-5.1".to_string()); - let permission_profile = config.permissions.permission_profile(); - let sandbox_policy = permission_profile - .to_legacy_sandbox_policy(config.cwd.as_path()) - .expect("configured permissions must have a legacy compatibility projection"); - TurnContextItem { - turn_id: None, - trace_id: None, - cwd, - current_date: None, - timezone: None, - approval_policy: config.permissions.approval_policy.value(), - sandbox_policy, - permission_profile: Some(permission_profile), - network: None, - file_system_sandbox_policy: None, - model, - personality: None, - collaboration_mode: None, - realtime_active: Some(false), - effort: config.model_reasoning_effort, - summary: config - .model_reasoning_summary - .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, - } - } - - #[tokio::test] - async fn read_session_cwd_prefers_latest_turn_context() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let config = build_config(&temp_dir).await?; - let first = temp_dir.path().join("first"); - let second = temp_dir.path().join("second"); - std::fs::create_dir_all(&first)?; - std::fs::create_dir_all(&second)?; - - let rollout_path = temp_dir.path().join("rollout.jsonl"); - let lines = vec![ - RolloutLine { - timestamp: "t0".to_string(), - item: RolloutItem::TurnContext(build_turn_context(&config, first)), - }, - RolloutLine { - timestamp: "t1".to_string(), - item: RolloutItem::TurnContext(build_turn_context(&config, second.clone())), - }, - ]; - let mut text = String::new(); - for line in lines { - text.push_str(&serde_json::to_string(&line).expect("serialize rollout")); - text.push('\n'); - } - std::fs::write(&rollout_path, text)?; - - let cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path)) - .await - .expect("expected cwd"); - assert_eq!(cwd, second); - Ok(()) - } - - #[tokio::test] - async fn should_prompt_when_meta_matches_current_but_latest_turn_differs() -> std::io::Result<()> - { - let temp_dir = TempDir::new()?; - let config = build_config(&temp_dir).await?; - let current = temp_dir.path().join("current"); - let latest = temp_dir.path().join("latest"); - std::fs::create_dir_all(¤t)?; - std::fs::create_dir_all(&latest)?; - - let rollout_path = temp_dir.path().join("rollout.jsonl"); - let session_meta = SessionMeta { - cwd: current.clone(), - ..SessionMeta::default() - }; - let lines = vec![ - RolloutLine { - timestamp: "t0".to_string(), - item: RolloutItem::SessionMeta(SessionMetaLine { - meta: session_meta, - git: None, - }), - }, - RolloutLine { - timestamp: "t1".to_string(), - item: RolloutItem::TurnContext(build_turn_context(&config, latest.clone())), - }, - ]; - let mut text = String::new(); - for line in lines { - text.push_str(&serde_json::to_string(&line).expect("serialize rollout")); - text.push('\n'); - } - std::fs::write(&rollout_path, text)?; - - let session_cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path)) - .await - .expect("expected cwd"); - assert_eq!(session_cwd, latest); - assert!(cwds_differ(¤t, &session_cwd)); - Ok(()) - } - #[tokio::test] async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> { let temp_dir = TempDir::new()?; @@ -2349,7 +2140,7 @@ trust_level = "untrusted" .build() .await?; assert_eq!( - trusted_config.permissions.approval_policy.value(), + AskForApproval::from(trusted_config.permissions.approval_policy.value()), AskForApproval::OnRequest ); @@ -2364,7 +2155,7 @@ trust_level = "untrusted" .build() .await?; assert_eq!( - untrusted_config.permissions.approval_policy.value(), + AskForApproval::from(untrusted_config.permissions.approval_policy.value()), AskForApproval::UnlessTrusted ); Ok(()) @@ -2413,95 +2204,4 @@ trust_level = "untrusted" ); Ok(()) } - - #[tokio::test] - async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let config = build_config(&temp_dir).await?; - let session_cwd = temp_dir.path().join("session"); - std::fs::create_dir_all(&session_cwd)?; - - let rollout_path = temp_dir.path().join("rollout.jsonl"); - let session_meta = SessionMeta { - cwd: session_cwd.clone(), - ..SessionMeta::default() - }; - let meta_line = RolloutLine { - timestamp: "t0".to_string(), - item: RolloutItem::SessionMeta(SessionMetaLine { - meta: session_meta, - git: None, - }), - }; - let text = format!( - "{}\n", - serde_json::to_string(&meta_line).expect("serialize meta") - ); - std::fs::write(&rollout_path, text)?; - - let cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path)) - .await - .expect("expected cwd"); - assert_eq!(cwd, session_cwd); - Ok(()) - } - - #[tokio::test] - async fn read_session_cwd_prefers_sqlite_when_thread_id_present() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); - - let thread_id = ThreadId::new(); - let rollout_cwd = temp_dir.path().join("rollout-cwd"); - let sqlite_cwd = temp_dir.path().join("sqlite-cwd"); - std::fs::create_dir_all(&rollout_cwd)?; - std::fs::create_dir_all(&sqlite_cwd)?; - - let rollout_path = temp_dir.path().join("rollout.jsonl"); - let rollout_line = RolloutLine { - timestamp: "t0".to_string(), - item: RolloutItem::TurnContext(build_turn_context(&config, rollout_cwd)), - }; - std::fs::write( - &rollout_path, - format!( - "{}\n", - serde_json::to_string(&rollout_line).expect("serialize rollout") - ), - )?; - - let runtime = codex_state::StateRuntime::init( - config.codex_home.to_path_buf(), - config.model_provider_id.clone(), - ) - .await - .map_err(std::io::Error::other)?; - runtime - .mark_backfill_complete(/*last_watermark*/ None) - .await - .map_err(std::io::Error::other)?; - - let mut builder = codex_state::ThreadMetadataBuilder::new( - thread_id, - rollout_path.clone(), - chrono::Utc::now(), - SessionSource::Cli, - ); - builder.cwd = sqlite_cwd.clone(); - let metadata = builder.build(config.model_provider_id.as_str()); - runtime - .upsert_thread(&metadata) - .await - .map_err(std::io::Error::other)?; - - let cwd = read_session_cwd(&config, thread_id, Some(&rollout_path)) - .await - .expect("expected cwd"); - assert_eq!(cwd, sqlite_cwd); - Ok(()) - } } diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 35230b61dc..64c6be4808 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -6,6 +6,7 @@ use codex_config::LoaderOverrides; use codex_tui::AppExitInfo; use codex_tui::Cli; use codex_tui::ExitReason; +use codex_tui::FinalOutput; use codex_tui::run_main; use codex_utils_cli::CliConfigOverrides; use supports_color::Stream; @@ -19,7 +20,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec, + /// Agent type shown in brackets when present, for example `worker`. + pub(crate) agent_role: Option, +} + #[derive(Clone, Copy)] struct AgentLabel<'a> { thread_id: Option, @@ -171,82 +173,153 @@ fn next_agent_word_motion_fallback( false } -pub(crate) fn spawn_end( - ev: CollabAgentSpawnEndEvent, - spawn_request: Option<&SpawnRequestSummary>, -) -> PlainHistoryCell { - let CollabAgentSpawnEndEvent { - call_id: _, - sender_thread_id: _, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - status: _, - .. - } = ev; +pub(crate) fn spawn_request_summary(item: &ThreadItem) -> Option { + match item { + ThreadItem::CollabAgentToolCall { + tool: CollabAgentTool::SpawnAgent, + model: Some(model), + reasoning_effort: Some(reasoning_effort), + .. + } => Some(SpawnRequestSummary { + model: model.clone(), + reasoning_effort: *reasoning_effort, + }), + _ => None, + } +} +pub(crate) fn tool_call_history_cell( + item: &ThreadItem, + cached_spawn_request: Option<&SpawnRequestSummary>, + mut agent_metadata: impl FnMut(ThreadId) -> AgentMetadata, +) -> Option { + let ThreadItem::CollabAgentToolCall { + tool, + status, + receiver_thread_ids, + prompt, + agents_states, + .. + } = item + else { + return None; + }; + + let first_receiver = receiver_thread_ids + .first() + .and_then(|id| parse_thread_id(id)); + let prompt = prompt.as_deref().unwrap_or_default(); + + match tool { + CollabAgentTool::SpawnAgent => { + if matches!(status, CollabAgentToolCallStatus::InProgress) { + return None; + } + let fallback_spawn_request = spawn_request_summary(item); + let spawn_request = cached_spawn_request.or(fallback_spawn_request.as_ref()); + Some(spawn_end( + first_receiver, + prompt, + spawn_request, + &mut agent_metadata, + )) + } + CollabAgentTool::SendInput => { + if matches!(status, CollabAgentToolCallStatus::InProgress) { + return None; + } + first_receiver.map(|receiver_thread_id| { + interaction_end(receiver_thread_id, prompt, &mut agent_metadata) + }) + } + CollabAgentTool::ResumeAgent => first_receiver.map(|receiver_thread_id| { + if matches!(status, CollabAgentToolCallStatus::InProgress) { + resume_begin(receiver_thread_id, &mut agent_metadata) + } else { + let state = first_agent_state(receiver_thread_ids, agents_states); + resume_end( + receiver_thread_id, + state, + "Agent resume failed", + &mut agent_metadata, + ) + } + }), + CollabAgentTool::Wait => { + if matches!(status, CollabAgentToolCallStatus::InProgress) { + Some(waiting_begin(receiver_thread_ids, &mut agent_metadata)) + } else { + Some(waiting_end( + receiver_thread_ids, + agents_states, + &mut agent_metadata, + )) + } + } + CollabAgentTool::CloseAgent => { + if matches!(status, CollabAgentToolCallStatus::InProgress) { + return None; + } + first_receiver + .map(|receiver_thread_id| close_end(receiver_thread_id, &mut agent_metadata)) + } + } +} + +fn spawn_end( + new_thread_id: Option, + prompt: &str, + spawn_request: Option<&SpawnRequestSummary>, + agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata, +) -> PlainHistoryCell { let title = match new_thread_id { Some(thread_id) => title_with_agent( "Spawned", - AgentLabel { - thread_id: Some(thread_id), - nickname: new_agent_nickname.as_deref(), - role: new_agent_role.as_deref(), - }, + agent_label(thread_id, &agent_metadata(thread_id)), spawn_request, ), None => title_text("Agent spawn failed"), }; let mut details = Vec::new(); - if let Some(line) = prompt_line(&prompt) { + if let Some(line) = prompt_line(prompt) { details.push(line); } collab_event(title, details) } -pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell { - let CollabAgentInteractionEndEvent { - call_id: _, - sender_thread_id: _, - receiver_thread_id, - receiver_agent_nickname, - receiver_agent_role, - prompt, - status: _, - } = ev; - +fn interaction_end( + receiver_thread_id: ThreadId, + prompt: &str, + agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata, +) -> PlainHistoryCell { let title = title_with_agent( "Sent input to", - AgentLabel { - thread_id: Some(receiver_thread_id), - nickname: receiver_agent_nickname.as_deref(), - role: receiver_agent_role.as_deref(), - }, + agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)), /*spawn_request*/ None, ); let mut details = Vec::new(); - if let Some(line) = prompt_line(&prompt) { + if let Some(line) = prompt_line(prompt) { details.push(line); } collab_event(title, details) } -pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { - let CollabWaitingBeginEvent { - sender_thread_id: _, - receiver_thread_ids, - receiver_agents, - call_id: _, - } = ev; - let receiver_agents = merge_wait_receivers(&receiver_thread_ids, receiver_agents); +fn waiting_begin( + receiver_thread_ids: &[String], + agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata, +) -> PlainHistoryCell { + let receiver_agents = receiver_thread_ids + .iter() + .filter_map(|thread_id| parse_thread_id(thread_id)) + .map(|thread_id| (thread_id, agent_metadata(thread_id))) + .collect::>(); let title = match receiver_agents.as_slice() { - [receiver] => title_with_agent( + [(thread_id, metadata)] => title_with_agent( "Waiting for", - agent_label_from_ref(receiver), + agent_label(*thread_id, metadata), /*spawn_request*/ None, ), [] => title_text("Waiting for agents"), @@ -256,7 +329,7 @@ pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { let details = if receiver_agents.len() > 1 { receiver_agents .iter() - .map(|receiver| agent_label_line(agent_label_from_ref(receiver))) + .map(|(thread_id, metadata)| agent_label_line(agent_label(*thread_id, metadata))) .collect() } else { Vec::new() @@ -265,85 +338,56 @@ pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { collab_event(title, details) } -pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { - let CollabWaitingEndEvent { - call_id: _, - sender_thread_id: _, - agent_statuses, - statuses, - } = ev; - let details = wait_complete_lines(&statuses, &agent_statuses); +fn waiting_end( + receiver_thread_ids: &[String], + agents_states: &std::collections::HashMap, + agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata, +) -> PlainHistoryCell { + let details = wait_complete_lines(receiver_thread_ids, agents_states, agent_metadata); collab_event(title_text("Finished waiting"), details) } -pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell { - let CollabCloseEndEvent { - call_id: _, - sender_thread_id: _, - receiver_thread_id, - receiver_agent_nickname, - receiver_agent_role, - status: _, - } = ev; - +fn close_end( + receiver_thread_id: ThreadId, + agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata, +) -> PlainHistoryCell { collab_event( title_with_agent( "Closed", - AgentLabel { - thread_id: Some(receiver_thread_id), - nickname: receiver_agent_nickname.as_deref(), - role: receiver_agent_role.as_deref(), - }, + agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)), /*spawn_request*/ None, ), Vec::new(), ) } -pub(crate) fn resume_begin(ev: CollabResumeBeginEvent) -> PlainHistoryCell { - let CollabResumeBeginEvent { - call_id: _, - sender_thread_id: _, - receiver_thread_id, - receiver_agent_nickname, - receiver_agent_role, - } = ev; - +fn resume_begin( + receiver_thread_id: ThreadId, + agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata, +) -> PlainHistoryCell { collab_event( title_with_agent( "Resuming", - AgentLabel { - thread_id: Some(receiver_thread_id), - nickname: receiver_agent_nickname.as_deref(), - role: receiver_agent_role.as_deref(), - }, + agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)), /*spawn_request*/ None, ), Vec::new(), ) } -pub(crate) fn resume_end(ev: CollabResumeEndEvent) -> PlainHistoryCell { - let CollabResumeEndEvent { - call_id: _, - sender_thread_id: _, - receiver_thread_id, - receiver_agent_nickname, - receiver_agent_role, - status, - } = ev; - +fn resume_end( + receiver_thread_id: ThreadId, + status: Option<&CollabAgentState>, + fallback_error: &str, + agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata, +) -> PlainHistoryCell { collab_event( title_with_agent( "Resumed", - AgentLabel { - thread_id: Some(receiver_thread_id), - nickname: receiver_agent_nickname.as_deref(), - role: receiver_agent_role.as_deref(), - }, + agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)), /*spawn_request*/ None, ), - vec![status_summary_line(&status)], + vec![status_summary_line(status, fallback_error)], ) } @@ -377,11 +421,15 @@ fn title_spans_line(mut spans: Vec>) -> Line<'static> { title.into() } -fn agent_label_from_ref(agent: &CollabAgentRef) -> AgentLabel<'_> { +fn parse_thread_id(thread_id: &str) -> Option { + ThreadId::from_string(thread_id).ok() +} + +fn agent_label(thread_id: ThreadId, metadata: &AgentMetadata) -> AgentLabel<'_> { AgentLabel { - thread_id: Some(agent.thread_id), - nickname: agent.agent_nickname.as_deref(), - role: agent.agent_role.as_deref(), + thread_id: Some(thread_id), + nickname: metadata.agent_nickname.as_deref(), + role: metadata.agent_role.as_deref(), } } @@ -444,113 +492,80 @@ fn prompt_line(prompt: &str) -> Option> { } } -fn merge_wait_receivers( - receiver_thread_ids: &[ThreadId], - mut receiver_agents: Vec, -) -> Vec { - if receiver_agents.is_empty() { - return receiver_thread_ids - .iter() - .map(|thread_id| CollabAgentRef { - thread_id: *thread_id, - agent_nickname: None, - agent_role: None, - }) - .collect(); - } - - let mut seen = receiver_agents - .iter() - .map(|agent| agent.thread_id) - .collect::>(); - for thread_id in receiver_thread_ids { - if seen.insert(*thread_id) { - receiver_agents.push(CollabAgentRef { - thread_id: *thread_id, - agent_nickname: None, - agent_role: None, - }); - } - } - receiver_agents -} - fn wait_complete_lines( - statuses: &HashMap, - agent_statuses: &[CollabAgentStatusEntry], + receiver_thread_ids: &[String], + agents_states: &std::collections::HashMap, + agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata, ) -> Vec> { - if statuses.is_empty() && agent_statuses.is_empty() { - return vec![Line::from(Span::from("No agents completed yet"))]; - } - - let entries = if agent_statuses.is_empty() { - let mut entries = statuses - .iter() - .map(|(thread_id, status)| CollabAgentStatusEntry { - thread_id: *thread_id, - agent_nickname: None, - agent_role: None, - status: status.clone(), - }) - .collect::>(); - entries.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); - entries - } else { - let mut entries = agent_statuses.to_vec(); - let seen = entries - .iter() - .map(|entry| entry.thread_id) - .collect::>(); - let mut extras = statuses - .iter() - .filter(|(thread_id, _)| !seen.contains(thread_id)) - .map(|(thread_id, status)| CollabAgentStatusEntry { - thread_id: *thread_id, - agent_nickname: None, - agent_role: None, - status: status.clone(), - }) - .collect::>(); - extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); - entries.extend(extras); - entries - }; - - entries - .into_iter() - .map(|entry| { - let CollabAgentStatusEntry { - thread_id, - agent_nickname, - agent_role, - status, - } = entry; - let mut spans = agent_label_spans(AgentLabel { - thread_id: Some(thread_id), - nickname: agent_nickname.as_deref(), - role: agent_role.as_deref(), - }); - spans.push(Span::from(": ").dim()); - spans.extend(status_summary_spans(&status)); - spans.into() + let mut seen = HashSet::new(); + let mut entries = receiver_thread_ids + .iter() + .filter_map(|thread_id| { + let parsed_thread_id = parse_thread_id(thread_id)?; + let status = agents_states.get(thread_id)?; + seen.insert(parsed_thread_id); + Some((parsed_thread_id, agent_metadata(parsed_thread_id), status)) }) - .collect() + .collect::>(); + + let mut extras = agents_states + .iter() + .filter_map(|(thread_id, status)| { + let parsed_thread_id = parse_thread_id(thread_id)?; + (!seen.contains(&parsed_thread_id)) + .then(|| (parsed_thread_id, agent_metadata(parsed_thread_id), status)) + }) + .collect::>(); + extras.sort_by(|left, right| left.0.to_string().cmp(&right.0.to_string())); + entries.extend(extras); + + if entries.is_empty() { + vec![Line::from(Span::from("No agents completed yet"))] + } else { + entries + .into_iter() + .map(|(thread_id, metadata, status)| { + let mut spans = agent_label_spans(agent_label(thread_id, &metadata)); + spans.push(Span::from(": ").dim()); + spans.extend(status_summary_spans(status)); + spans.into() + }) + .collect() + } } -fn status_summary_line(status: &AgentStatus) -> Line<'static> { - status_summary_spans(status).into() +fn first_agent_state<'a>( + receiver_thread_ids: &[String], + agents_states: &'a std::collections::HashMap, +) -> Option<&'a CollabAgentState> { + receiver_thread_ids + .iter() + .find_map(|thread_id| agents_states.get(thread_id)) + .or_else(|| { + agents_states + .iter() + .min_by(|left, right| left.0.cmp(right.0)) + .map(|(_, status)| status) + }) } -fn status_summary_spans(status: &AgentStatus) -> Vec> { +fn status_summary_line(status: Option<&CollabAgentState>, fallback_error: &str) -> Line<'static> { match status { - AgentStatus::PendingInit => vec![Span::from("Pending init").cyan()], - AgentStatus::Running => vec![Span::from("Running").cyan().bold()], + Some(status) => status_summary_spans(status).into(), + None => error_summary_spans(fallback_error).into(), + } +} + +fn status_summary_spans(status: &CollabAgentState) -> Vec> { + match status.status { + CollabAgentStatus::PendingInit => vec![Span::from("Pending init").cyan()], + CollabAgentStatus::Running => vec![Span::from("Running").cyan().bold()], // Allow `.yellow()` #[allow(clippy::disallowed_methods)] - AgentStatus::Interrupted => vec![Span::from("Interrupted").yellow()], - AgentStatus::Completed(message) => { + CollabAgentStatus::Interrupted => vec![Span::from("Interrupted").yellow()], + CollabAgentStatus::Completed => { let mut spans = vec![Span::from("Completed").green()]; - if let Some(message) = message.as_ref() { + if let Some(message) = status.message.as_ref() { let message_preview = truncate_text( &message.split_whitespace().collect::>().join(" "), COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES, @@ -562,23 +577,27 @@ fn status_summary_spans(status: &AgentStatus) -> Vec> { } spans } - AgentStatus::Errored(error) => { - let mut spans = vec![Span::from("Error").red()]; - let error_preview = truncate_text( - &error.split_whitespace().collect::>().join(" "), - COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES, - ); - if !error_preview.is_empty() { - spans.push(Span::from(" - ").dim()); - spans.push(Span::from(error_preview)); - } - spans + CollabAgentStatus::Errored => { + error_summary_spans(status.message.as_deref().unwrap_or("Agent errored")) } - AgentStatus::Shutdown => vec![Span::from("Shutdown")], - AgentStatus::NotFound => vec![Span::from("Not found").red()], + CollabAgentStatus::Shutdown => vec![Span::from("Shutdown")], + CollabAgentStatus::NotFound => vec![Span::from("Not found").red()], } } +fn error_summary_spans(error: &str) -> Vec> { + let mut spans = vec![Span::from("Error").red()]; + let error_preview = truncate_text( + &error.split_whitespace().collect::>().join(" "), + COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES, + ); + if !error_preview.is_empty() { + spans.push(Span::from(" - ").dim()); + spans.push(Span::from(error_preview)); + } + spans +} + #[cfg(test)] mod tests { use super::*; @@ -591,6 +610,7 @@ mod tests { use pretty_assertions::assert_eq; use ratatui::style::Color; use ratatui::style::Modifier; + use std::collections::HashMap; #[test] fn collab_events_snapshot() { @@ -601,79 +621,108 @@ mod tests { let bob_id = ThreadId::from_string("00000000-0000-0000-0000-000000000003") .expect("valid bob thread id"); - let spawn = spawn_end( - CollabAgentSpawnEndEvent { - call_id: "call-spawn".to_string(), - sender_thread_id, - new_thread_id: Some(robie_id), - new_agent_nickname: Some("Robie".to_string()), - new_agent_role: Some("explorer".to_string()), - prompt: "Compute 11! and reply with just the integer result.".to_string(), - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::High, - status: AgentStatus::PendingInit, + let spawn = tool_call_history_cell( + &ThreadItem::CollabAgentToolCall { + id: "call-spawn".to_string(), + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: vec![robie_id.to_string()], + prompt: Some("Compute 11! and reply with just the integer result.".to_string()), + model: Some("gpt-5".to_string()), + reasoning_effort: Some(ReasoningEffortConfig::High), + agents_states: HashMap::from([( + robie_id.to_string(), + agent_state(CollabAgentStatus::PendingInit, None), + )]), }, - Some(&SpawnRequestSummary { - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::High, - }), - ); + /*cached_spawn_request*/ None, + |thread_id| metadata_for(thread_id, robie_id, bob_id), + ) + .expect("spawn item renders"); - let send = interaction_end(CollabAgentInteractionEndEvent { - call_id: "call-send".to_string(), - sender_thread_id, - receiver_thread_id: robie_id, - receiver_agent_nickname: Some("Robie".to_string()), - receiver_agent_role: Some("explorer".to_string()), - prompt: "Please continue and return the answer only.".to_string(), - status: AgentStatus::Running, - }); + let send = tool_call_history_cell( + &ThreadItem::CollabAgentToolCall { + id: "call-send".to_string(), + tool: CollabAgentTool::SendInput, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: vec![robie_id.to_string()], + prompt: Some("Please continue and return the answer only.".to_string()), + model: None, + reasoning_effort: None, + agents_states: HashMap::from([( + robie_id.to_string(), + agent_state(CollabAgentStatus::Running, None), + )]), + }, + /*cached_spawn_request*/ None, + |thread_id| metadata_for(thread_id, robie_id, bob_id), + ) + .expect("send-input item renders"); - let waiting = waiting_begin(CollabWaitingBeginEvent { - sender_thread_id, - receiver_thread_ids: vec![robie_id], - receiver_agents: vec![CollabAgentRef { - thread_id: robie_id, - agent_nickname: Some("Robie".to_string()), - agent_role: Some("explorer".to_string()), - }], - call_id: "call-wait".to_string(), - }); + let waiting = tool_call_history_cell( + &ThreadItem::CollabAgentToolCall { + id: "call-wait".to_string(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: vec![robie_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }, + /*cached_spawn_request*/ None, + |thread_id| metadata_for(thread_id, robie_id, bob_id), + ) + .expect("wait begin item renders"); - let mut statuses = HashMap::new(); - statuses.insert( - robie_id, - AgentStatus::Completed(Some("39916800".to_string())), - ); - statuses.insert(bob_id, AgentStatus::Errored("tool timeout".to_string())); - let finished = waiting_end(CollabWaitingEndEvent { - sender_thread_id, - call_id: "call-wait".to_string(), - agent_statuses: vec![ - CollabAgentStatusEntry { - thread_id: robie_id, - agent_nickname: Some("Robie".to_string()), - agent_role: Some("explorer".to_string()), - status: AgentStatus::Completed(Some("39916800".to_string())), - }, - CollabAgentStatusEntry { - thread_id: bob_id, - agent_nickname: Some("Bob".to_string()), - agent_role: Some("worker".to_string()), - status: AgentStatus::Errored("tool timeout".to_string()), - }, - ], - statuses, - }); + let finished = tool_call_history_cell( + &ThreadItem::CollabAgentToolCall { + id: "call-wait".to_string(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: vec![robie_id.to_string(), bob_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::from([ + ( + robie_id.to_string(), + agent_state(CollabAgentStatus::Completed, Some("39916800")), + ), + ( + bob_id.to_string(), + agent_state(CollabAgentStatus::Errored, Some("tool timeout")), + ), + ]), + }, + /*cached_spawn_request*/ None, + |thread_id| metadata_for(thread_id, robie_id, bob_id), + ) + .expect("wait end item renders"); - let close = close_end(CollabCloseEndEvent { - call_id: "call-close".to_string(), - sender_thread_id, - receiver_thread_id: robie_id, - receiver_agent_nickname: Some("Robie".to_string()), - receiver_agent_role: Some("explorer".to_string()), - status: AgentStatus::Completed(Some("39916800".to_string())), - }); + let close = tool_call_history_cell( + &ThreadItem::CollabAgentToolCall { + id: "call-close".to_string(), + tool: CollabAgentTool::CloseAgent, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: vec![robie_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::from([( + robie_id.to_string(), + agent_state(CollabAgentStatus::Completed, Some("39916800")), + )]), + }, + /*cached_spawn_request*/ None, + |thread_id| metadata_for(thread_id, robie_id, bob_id), + ) + .expect("close item renders"); let snapshot = [spawn, send, waiting, finished, close] .iter() @@ -739,23 +788,25 @@ mod tests { .expect("valid sender thread id"); let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002") .expect("valid robie thread id"); - let cell = spawn_end( - CollabAgentSpawnEndEvent { - call_id: "call-spawn".to_string(), - sender_thread_id, - new_thread_id: Some(robie_id), - new_agent_nickname: Some("Robie".to_string()), - new_agent_role: Some("explorer".to_string()), - prompt: String::new(), - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::High, - status: AgentStatus::PendingInit, + let cell = tool_call_history_cell( + &ThreadItem::CollabAgentToolCall { + id: "call-spawn".to_string(), + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: vec![robie_id.to_string()], + prompt: Some(String::new()), + model: Some("gpt-5".to_string()), + reasoning_effort: Some(ReasoningEffortConfig::High), + agents_states: HashMap::from([( + robie_id.to_string(), + agent_state(CollabAgentStatus::PendingInit, None), + )]), }, - Some(&SpawnRequestSummary { - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::High, - }), - ); + /*cached_spawn_request*/ None, + |thread_id| metadata_for(thread_id, robie_id, ThreadId::new()), + ) + .expect("spawn item renders"); let lines = cell.display_lines(/*width*/ 200); let title = &lines[0]; @@ -776,18 +827,52 @@ mod tests { let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002") .expect("valid robie thread id"); - let cell = resume_end(CollabResumeEndEvent { - call_id: "call-resume".to_string(), - sender_thread_id, - receiver_thread_id: robie_id, - receiver_agent_nickname: Some("Robie".to_string()), - receiver_agent_role: Some("explorer".to_string()), - status: AgentStatus::Interrupted, - }); + let cell = tool_call_history_cell( + &ThreadItem::CollabAgentToolCall { + id: "call-resume".to_string(), + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: vec![robie_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::from([( + robie_id.to_string(), + agent_state(CollabAgentStatus::Interrupted, None), + )]), + }, + /*cached_spawn_request*/ None, + |thread_id| metadata_for(thread_id, robie_id, ThreadId::new()), + ) + .expect("resume item renders"); assert_snapshot!("collab_resume_interrupted", cell_to_text(&cell)); } + fn agent_state(status: CollabAgentStatus, message: Option<&str>) -> CollabAgentState { + CollabAgentState { + status, + message: message.map(str::to_string), + } + } + + fn metadata_for(thread_id: ThreadId, robie_id: ThreadId, bob_id: ThreadId) -> AgentMetadata { + if thread_id == robie_id { + AgentMetadata { + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + } + } else if thread_id == bob_id { + AgentMetadata { + agent_nickname: Some("Bob".to_string()), + agent_role: Some("worker".to_string()), + } + } else { + AgentMetadata::default() + } + } + fn cell_to_text(cell: &PlainHistoryCell) -> String { cell.display_lines(/*width*/ 200) .iter() diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 4698fabd92..8508047018 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1016,7 +1016,6 @@ mod tests { use codex_cloud_requirements::cloud_requirements_loader_for_storage; use codex_config::types::AuthCredentialsStoreMode; - use codex_protocol::protocol::SessionSource; use pretty_assertions::assert_eq; use std::sync::Arc; use tempfile::TempDir; @@ -1047,7 +1046,8 @@ mod tests { codex_app_server_client::EnvironmentManager::default_for_tests(), ), config_warnings: Vec::new(), - session_source: SessionSource::Cli, + session_source: serde_json::from_value(serde_json::json!("cli")) + .expect("cli session source should deserialize"), enable_codex_api_key_env: false, client_name: "test".to_string(), client_version: "test".to_string(), diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 7878ea8551..46446b3694 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -912,8 +912,8 @@ fn render_offset_content( #[cfg(test)] mod tests { use super::*; - use codex_protocol::protocol::ExecCommandSource; - use codex_protocol::protocol::ReviewDecision; + use crate::approval_display::ReviewDecision; + use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; use insta::assert_snapshot; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -921,12 +921,12 @@ mod tests { use std::sync::Arc; use std::time::Duration; + use crate::diff_model::FileChange; use crate::exec_cell::CommandOutput; use crate::history_cell; use crate::history_cell::HistoryCell; use crate::history_cell::new_patch_event; use codex_protocol::parse_command::ParsedCommand; - use codex_protocol::protocol::FileChange; use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::text::Text; diff --git a/codex-rs/tui/src/permission_compat.rs b/codex-rs/tui/src/permission_compat.rs index 97e4436ef6..93dc40e19f 100644 --- a/codex-rs/tui/src/permission_compat.rs +++ b/codex-rs/tui/src/permission_compat.rs @@ -2,8 +2,6 @@ //! legacy shapes still required by older or remote app-server APIs. use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::Path; @@ -16,18 +14,7 @@ pub(crate) fn legacy_compatible_permission_profile( } let file_system_policy = permission_profile.file_system_sandbox_policy(); - compatibility_workspace_write_profile( - &file_system_policy, - permission_profile.network_sandbox_policy(), - cwd, - ) -} - -fn compatibility_workspace_write_profile( - file_system_policy: &FileSystemSandboxPolicy, - network_policy: NetworkSandboxPolicy, - cwd: &Path, -) -> PermissionProfile { + let network_policy = permission_profile.network_sandbox_policy(); let cwd_abs = AbsolutePathBuf::from_absolute_path(cwd).ok(); let writable_roots = file_system_policy .get_writable_roots_with_cwd(cwd) @@ -57,19 +44,22 @@ fn compatibility_workspace_write_profile( #[cfg(test)] mod tests { use super::*; - use codex_protocol::models::ManagedFileSystemPermissions; - use codex_protocol::permissions::FileSystemAccessMode; - use codex_protocol::permissions::FileSystemPath; - use codex_protocol::permissions::FileSystemSandboxEntry; - use codex_protocol::permissions::FileSystemSpecialPath; + use codex_app_server_protocol::FileSystemAccessMode; + use codex_app_server_protocol::FileSystemPath; + use codex_app_server_protocol::FileSystemSandboxEntry; + use codex_app_server_protocol::FileSystemSpecialPath; + use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; + use codex_app_server_protocol::PermissionProfileFileSystemPermissions; + use codex_app_server_protocol::PermissionProfileNetworkPermissions; use pretty_assertions::assert_eq; #[test] fn compatibility_profile_preserves_unbridgeable_write_roots() { let cwd = AbsolutePathBuf::try_from("/workspace/project").expect("absolute cwd"); let extra_root = AbsolutePathBuf::try_from("/workspace/extra").expect("absolute root"); - let permission_profile = PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Restricted { + let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -86,8 +76,8 @@ mod tests { ], glob_scan_max_depth: None, }, - network: NetworkSandboxPolicy::Restricted, - }; + } + .into(); let compatibility_profile = legacy_compatible_permission_profile(&permission_profile, cwd.as_path()); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 24c46e41d2..dc148f0059 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -7,6 +7,7 @@ use crate::app_server_session::AppServerSession; use crate::diff_render::display_path_for; use crate::key_hint; use crate::legacy_core::config::Config; +use crate::session_resume::resolve_session_thread_id; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; use crate::tui::Tui; @@ -561,11 +562,8 @@ impl PickerState { Some(thread_id) => Some(thread_id), None => match path.as_ref() { Some(path) => { - crate::resolve_session_thread_id( - path.as_path(), - /*id_str_if_uuid*/ None, - ) - .await + resolve_session_thread_id(path.as_path(), /*id_str_if_uuid*/ None) + .await } None => None, }, diff --git a/codex-rs/tui/src/session_resume.rs b/codex-rs/tui/src/session_resume.rs new file mode 100644 index 0000000000..5e7b35d154 --- /dev/null +++ b/codex-rs/tui/src/session_resume.rs @@ -0,0 +1,432 @@ +//! Resolve saved-session state needed before resuming or forking a thread. +//! +//! The app-server API owns normal thread lifecycle data. This module handles the local fallback +//! path used before a thread has been resumed, where TUI may need the saved cwd or model from the +//! local rollout JSONL to rebuild config or render an inactive session accurately. + +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use crate::cwd_prompt; +use crate::cwd_prompt::CwdPromptAction; +use crate::cwd_prompt::CwdPromptOutcome; +use crate::cwd_prompt::CwdSelection; +use crate::legacy_core::config::Config; +use crate::tui::Tui; +use codex_protocol::ThreadId; +use codex_rollout::state_db::get_state_db; +use codex_utils_path as path_utils; +use serde::Deserialize; +use serde_json::Value; +use tokio::io::AsyncBufReadExt; + +#[derive(Deserialize)] +struct SessionMetadata { + id: ThreadId, + cwd: PathBuf, +} + +#[derive(Deserialize)] +struct LatestTurnContext { + cwd: PathBuf, + model: String, +} + +#[derive(Deserialize)] +struct RawRecord { + #[serde(rename = "type")] + item_type: String, + payload: Option, +} + +pub(crate) enum ResolveCwdOutcome { + Continue(Option), + Exit, +} + +pub(crate) async fn resolve_session_thread_id( + path: &Path, + id_str_if_uuid: Option<&str>, +) -> Option { + match id_str_if_uuid { + Some(id_str) => ThreadId::from_string(id_str).ok(), + None => read_session_metadata(path) + .await + .ok() + .map(|metadata| metadata.id), + } +} + +pub(crate) async fn read_session_model( + config: &Config, + thread_id: ThreadId, + path: Option<&Path>, +) -> Option { + if let Some(state_db_ctx) = get_state_db(config).await + && let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await + && let Some(model) = metadata.model + { + return Some(model); + } + + let path = path?; + read_latest_turn_context(path).await.map(|item| item.model) +} + +pub(crate) async fn resolve_cwd_for_resume_or_fork( + tui: &mut Tui, + config: &Config, + current_cwd: &Path, + thread_id: ThreadId, + path: Option<&Path>, + action: CwdPromptAction, + allow_prompt: bool, +) -> color_eyre::Result { + let Some(history_cwd) = read_session_cwd(config, thread_id, path).await else { + return Ok(ResolveCwdOutcome::Continue(None)); + }; + if allow_prompt && cwds_differ(current_cwd, &history_cwd) { + let selection_outcome = + cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?; + return Ok(match selection_outcome { + CwdPromptOutcome::Selection(CwdSelection::Current) => { + ResolveCwdOutcome::Continue(Some(current_cwd.to_path_buf())) + } + CwdPromptOutcome::Selection(CwdSelection::Session) => { + ResolveCwdOutcome::Continue(Some(history_cwd)) + } + CwdPromptOutcome::Exit => ResolveCwdOutcome::Exit, + }); + } + Ok(ResolveCwdOutcome::Continue(Some(history_cwd))) +} + +async fn read_session_cwd( + config: &Config, + thread_id: ThreadId, + path: Option<&Path>, +) -> Option { + if let Some(state_db_ctx) = get_state_db(config).await + && let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await + { + return Some(metadata.cwd); + } + + // Prefer the latest TurnContext cwd so resume/fork reflects the most recent + // session directory (for the changed-cwd prompt) when DB data is unavailable. + // The alternative would be mutating the session metadata line when the session cwd + // changes, but the rollout is an append-only JSONL log and rewriting the head + // would be error-prone. + let path = path?; + if let Some(cwd) = read_latest_turn_context(path).await.map(|item| item.cwd) { + return Some(cwd); + } + match read_session_metadata(path).await { + Ok(metadata) => Some(metadata.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + } +} + +pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool { + !path_utils::paths_match_after_normalization(current_cwd, session_cwd) +} + +async fn read_session_metadata(path: &Path) -> io::Result { + let file = tokio::fs::File::open(path).await?; + let reader = tokio::io::BufReader::new(file); + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await? { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let record = serde_json::from_str::(trimmed).map_err(|err| { + io::Error::other(format!( + "failed to parse rollout line in {}: {err}", + path.display() + )) + })?; + if record.item_type != "session_meta" { + return Err(io::Error::other(format!( + "rollout at {} does not start with session metadata", + path.display() + ))); + } + let payload = record.payload.ok_or_else(|| { + io::Error::other(format!( + "session metadata in {} is missing a payload", + path.display() + )) + })?; + return serde_json::from_value(payload).map_err(|err| { + io::Error::other(format!( + "failed to parse session metadata in {}: {err}", + path.display() + )) + }); + } + + Err(io::Error::other(format!( + "rollout at {} is empty", + path.display() + ))) +} + +async fn read_latest_turn_context(path: &Path) -> Option { + let text = tokio::fs::read_to_string(path).await.ok()?; + for line in text.lines().rev() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(record) = serde_json::from_str::(trimmed) else { + continue; + }; + if record.item_type != "turn_context" { + continue; + } + let Some(payload) = record.payload else { + continue; + }; + if let Ok(item) = serde_json::from_value(payload) { + return Some(item); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_core::config::ConfigBuilder; + use codex_features::Feature; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + async fn build_config(temp_dir: &TempDir) -> std::io::Result { + ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await + } + + fn rollout_line( + timestamp: &str, + item_type: &str, + payload: serde_json::Value, + ) -> serde_json::Value { + serde_json::json!({ + "timestamp": timestamp, + "type": item_type, + "payload": payload, + }) + } + + fn turn_context_line(config: &Config, cwd: PathBuf, timestamp: &str) -> serde_json::Value { + let model = config + .model + .clone() + .unwrap_or_else(|| "gpt-5.1".to_string()); + rollout_line( + timestamp, + "turn_context", + serde_json::json!({ + "cwd": cwd, + "model": model, + }), + ) + } + + fn session_meta_line(thread_id: ThreadId, cwd: PathBuf, timestamp: &str) -> serde_json::Value { + rollout_line( + timestamp, + "session_meta", + serde_json::json!({ + "id": thread_id.to_string(), + "timestamp": timestamp, + "cwd": cwd, + "originator": "test", + "cli_version": "test", + }), + ) + } + + fn write_rollout_lines(path: &Path, lines: &[serde_json::Value]) -> std::io::Result<()> { + let mut text = String::new(); + for line in lines { + text.push_str(&serde_json::to_string(line).expect("serialize rollout")); + text.push('\n'); + } + std::fs::write(path, text) + } + + #[tokio::test] + async fn read_session_cwd_returns_none_without_sqlite_or_rollout_path() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + + let cwd = read_session_cwd(&config, ThreadId::new(), /*path*/ None).await; + + assert_eq!(cwd, None); + Ok(()) + } + + #[tokio::test] + async fn read_session_cwd_prefers_latest_turn_context() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let first = temp_dir.path().join("first"); + let second = temp_dir.path().join("second"); + std::fs::create_dir_all(&first)?; + std::fs::create_dir_all(&second)?; + + let rollout_path = temp_dir.path().join("rollout.jsonl"); + write_rollout_lines( + &rollout_path, + &[ + turn_context_line(&config, first, "t0"), + turn_context_line(&config, second.clone(), "t1"), + ], + )?; + + let cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path)) + .await + .expect("expected cwd"); + assert_eq!(cwd, second); + Ok(()) + } + + #[tokio::test] + async fn should_prompt_when_meta_matches_current_but_latest_turn_differs() -> std::io::Result<()> + { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let current = temp_dir.path().join("current"); + let latest = temp_dir.path().join("latest"); + std::fs::create_dir_all(¤t)?; + std::fs::create_dir_all(&latest)?; + + let rollout_path = temp_dir.path().join("rollout.jsonl"); + write_rollout_lines( + &rollout_path, + &[ + session_meta_line(ThreadId::new(), current.clone(), "t0"), + turn_context_line(&config, latest.clone(), "t1"), + ], + )?; + + let session_cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path)) + .await + .expect("expected cwd"); + assert_eq!(session_cwd, latest); + assert!(cwds_differ(¤t, &session_cwd)); + Ok(()) + } + + #[tokio::test] + async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let session_cwd = temp_dir.path().join("session"); + std::fs::create_dir_all(&session_cwd)?; + + let rollout_path = temp_dir.path().join("rollout.jsonl"); + write_rollout_lines( + &rollout_path, + &[session_meta_line( + ThreadId::new(), + session_cwd.clone(), + "t0", + )], + )?; + + let cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path)) + .await + .expect("expected cwd"); + assert_eq!(cwd, session_cwd); + Ok(()) + } + + #[tokio::test] + async fn read_session_cwd_prefers_sqlite_when_thread_id_present() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let mut config = build_config(&temp_dir).await?; + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); + + let thread_id = ThreadId::new(); + let rollout_cwd = temp_dir.path().join("rollout-cwd"); + let sqlite_cwd = temp_dir.path().join("sqlite-cwd"); + std::fs::create_dir_all(&rollout_cwd)?; + std::fs::create_dir_all(&sqlite_cwd)?; + + let rollout_path = temp_dir.path().join("rollout.jsonl"); + write_rollout_lines( + &rollout_path, + &[turn_context_line(&config, rollout_cwd, "t0")], + )?; + + let runtime = codex_state::StateRuntime::init( + config.codex_home.to_path_buf(), + config.model_provider_id.clone(), + ) + .await + .map_err(std::io::Error::other)?; + runtime + .mark_backfill_complete(/*last_watermark*/ None) + .await + .map_err(std::io::Error::other)?; + + let mut builder = codex_state::ThreadMetadataBuilder::new( + thread_id, + rollout_path.clone(), + chrono::Utc::now(), + serde_json::from_value(serde_json::json!("cli")) + .expect("cli session source should deserialize"), + ); + builder.cwd = sqlite_cwd.clone(); + let metadata = builder.build(config.model_provider_id.as_str()); + runtime + .upsert_thread(&metadata) + .await + .map_err(std::io::Error::other)?; + + let cwd = read_session_cwd(&config, thread_id, Some(&rollout_path)) + .await + .expect("expected cwd"); + assert_eq!(cwd, sqlite_cwd); + Ok(()) + } + + #[tokio::test] + async fn resolve_session_thread_id_reads_minimal_session_metadata() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let thread_id = ThreadId::new(); + let rollout_path = temp_dir.path().join("rollout.jsonl"); + write_rollout_lines( + &rollout_path, + &[session_meta_line( + thread_id, + temp_dir.path().to_path_buf(), + "t0", + )], + )?; + + let resolved = resolve_session_thread_id(&rollout_path, /*id_str_if_uuid*/ None).await; + + assert_eq!(resolved, Some(thread_id)); + Ok(()) + } +} diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs new file mode 100644 index 0000000000..2d34c11717 --- /dev/null +++ b/codex-rs/tui/src/session_state.rs @@ -0,0 +1,41 @@ +//! Canonical TUI session state shared across app-server routing, chat display, and status UI. +//! +//! The app-server API is the boundary for session lifecycle events. Once those responses enter +//! TUI, this module holds the small internal state shape used by app orchestration and widgets. + +use std::path::PathBuf; + +use codex_app_server_protocol::AskForApproval; +use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SessionNetworkProxyRuntime { + pub(crate) http_addr: String, + pub(crate) socks_addr: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ThreadSessionState { + pub(crate) thread_id: ThreadId, + pub(crate) forked_from_id: Option, + pub(crate) fork_parent_title: Option, + pub(crate) thread_name: Option, + pub(crate) model: String, + pub(crate) model_provider_id: String, + pub(crate) service_tier: Option, + pub(crate) approval_policy: AskForApproval, + pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, + /// Canonical active permissions for this session. Legacy app-server + /// responses are converted to a profile at ingestion time using the + /// response cwd so cached sessions do not reinterpret cwd-bound grants. + pub(crate) permission_profile: PermissionProfile, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) instruction_source_paths: Vec, + pub(crate) reasoning_effort: Option, + pub(crate) history_log_id: u64, + pub(crate) history_entry_count: u64, + pub(crate) network_proxy: Option, + pub(crate) rollout_path: Option, +} diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index b9366df208..149fd82a56 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -3,17 +3,17 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::with_border_with_inner_width; use crate::legacy_core::config::Config; +use crate::token_usage::TokenUsage; +use crate::token_usage::TokenUsageInfo; use crate::version::CODEX_CLI_VERSION; use chrono::DateTime; use chrono::Local; +use codex_app_server_protocol::AskForApproval; use codex_model_provider_info::WireApi; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::TokenUsage; -use codex_protocol::protocol::TokenUsageInfo; use codex_utils_sandbox_summary::summarize_permission_profile; use ratatui::prelude::*; use ratatui::style::Stylize; @@ -244,6 +244,8 @@ impl StatusHistoryCell { agents_summary: String, refreshing_rate_limits: bool, ) -> (Self, StatusHistoryHandle) { + let approval_policy = AskForApproval::from(config.permissions.approval_policy.value()); + let permission_profile = config.permissions.permission_profile(); let mut config_entries = vec![ ("workdir", config.cwd.display().to_string()), ("model", model_name.to_string()), @@ -254,10 +256,7 @@ impl StatusHistoryCell { ), ( "sandbox", - summarize_permission_profile( - &config.permissions.permission_profile(), - config.cwd.as_path(), - ), + summarize_permission_profile(&permission_profile, config.cwd.as_path()), ), ]; if config.model_provider.wire_api == WireApi::Responses { @@ -280,13 +279,12 @@ impl StatusHistoryCell { .find(|(k, _)| *k == "approval") .map(|(_, v)| v.clone()) .unwrap_or_else(|| "".to_string()); - let permission_profile = config.permissions.permission_profile(); let sandbox = status_permission_summary(&permission_profile, config.cwd.as_path()); - let permissions = if config.permissions.approval_policy.value() == AskForApproval::OnRequest + let permissions = if approval_policy == AskForApproval::OnRequest && permission_profile == PermissionProfile::workspace_write() { "Default".to_string() - } else if config.permissions.approval_policy.value() == AskForApproval::Never + } else if approval_policy == AskForApproval::Never && permission_profile == PermissionProfile::Disabled { "Full Access".to_string() diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 559da21e84..3be813beb9 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -13,9 +13,9 @@ use chrono::DateTime; use chrono::Duration as ChronoDuration; use chrono::Local; use chrono::Utc; -use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; -use codex_protocol::protocol::RateLimitSnapshot; -use codex_protocol::protocol::RateLimitWindow; +use codex_app_server_protocol::CreditsSnapshot as CoreCreditsSnapshot; +use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::RateLimitWindow; const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; const STATUS_LIMIT_BAR_FILLED: &str = "█"; @@ -79,9 +79,9 @@ impl RateLimitWindowDisplay { let resets_at = resets_at_utc.map(|dt| format_reset_timestamp(dt, captured_at)); Self { - used_percent: window.used_percent, + used_percent: f64::from(window.used_percent), resets_at, - window_minutes: window.window_minutes, + window_minutes: window.window_duration_mins, } } } diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index b6b38cad10..438503160c 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -8,26 +8,69 @@ use crate::legacy_core::config::ConfigBuilder; use crate::status::StatusAccountDisplay; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; +use crate::token_usage::TokenUsage; +use crate::token_usage::TokenUsageInfo; use chrono::Duration as ChronoDuration; use chrono::TimeZone; use chrono::Utc; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::CreditsSnapshot; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; +use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; +use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::RateLimitWindow; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::CreditsSnapshot; -use codex_protocol::protocol::NetworkSandboxPolicy; -use codex_protocol::protocol::RateLimitSnapshot; -use codex_protocol::protocol::RateLimitWindow; -use codex_protocol::protocol::TokenUsage; -use codex_protocol::protocol::TokenUsageInfo; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::*; use tempfile::TempDir; +fn app_server_workspace_write_profile(network_enabled: bool) -> PermissionProfile { + AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { + enabled: network_enabled, + }, + file_system: PermissionProfileFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { subpath: None }, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::SlashTmp, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + access: FileSystemAccessMode::Write, + }, + ], + glob_scan_max_depth: None, + }, + } + .into() +} + async fn test_config(temp_home: &TempDir) -> Config { let mut config = ConfigBuilder::default() .codex_home(temp_home.path().to_path_buf()) @@ -36,11 +79,8 @@ async fn test_config(temp_home: &TempDir) -> Config { .expect("load config"); config .permissions - .set_permission_profile(PermissionProfile::workspace_write_with( - &[], - NetworkSandboxPolicy::Enabled, - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, + .set_permission_profile(app_server_workspace_write_profile( + /*network_enabled*/ true, )) .expect("set permission profile"); config @@ -167,13 +207,13 @@ async fn status_snapshot_includes_reasoning_details() { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: 72.5, - window_minutes: Some(300), + used_percent: 72, + window_duration_mins: Some(300), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 600)), }), secondary: Some(RateLimitWindow { - used_percent: 45.0, - window_minutes: Some(10080), + used_percent: 45, + window_duration_mins: Some(10080), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)), }), credits: None, @@ -220,16 +260,13 @@ async fn status_permissions_non_default_workspace_write_is_custom() { config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); config.cwd = test_path_buf("/workspace/tests").abs(); config .permissions - .set_permission_profile(PermissionProfile::workspace_write_with( - &[], - NetworkSandboxPolicy::Enabled, - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, + .set_permission_profile(app_server_workspace_write_profile( + /*network_enabled*/ true, )) .expect("set permission profile"); @@ -246,14 +283,17 @@ async fn status_permissions_full_disk_managed_with_network_is_danger_full_access config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); config .permissions - .set_permission_profile(PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Unrestricted, - network: NetworkSandboxPolicy::Enabled, - }) + .set_permission_profile( + AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: true }, + file_system: PermissionProfileFileSystemPermissions::Unrestricted, + } + .into(), + ) .expect("set permission profile"); assert_eq!( @@ -269,14 +309,17 @@ async fn status_permissions_full_disk_managed_without_network_is_external_sandbo config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); config .permissions - .set_permission_profile(PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Unrestricted, - network: NetworkSandboxPolicy::Restricted, - }) + .set_permission_profile( + AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Unrestricted, + } + .into(), + ) .expect("set permission profile"); assert_eq!( @@ -364,8 +407,8 @@ async fn status_snapshot_includes_monthly_limit() { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: 12.0, - window_minutes: Some(43_200), + used_percent: 12, + window_duration_mins: Some(43_200), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 86_400)), }), secondary: None, @@ -670,8 +713,8 @@ async fn status_snapshot_truncates_in_narrow_terminal() { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: 72.5, - window_minutes: Some(300), + used_percent: 72, + window_duration_mins: Some(300), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 600)), }), secondary: None, @@ -830,13 +873,13 @@ async fn status_snapshot_shows_refreshing_limits_notice() { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: 45.0, - window_minutes: Some(300), + used_percent: 45, + window_duration_mins: Some(300), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 900)), }), secondary: Some(RateLimitWindow { - used_percent: 30.0, - window_minutes: Some(10_080), + used_percent: 30, + window_duration_mins: Some(10_080), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)), }), credits: None, @@ -897,13 +940,13 @@ async fn status_snapshot_includes_credits_and_limits() { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: 45.0, - window_minutes: Some(300), + used_percent: 45, + window_duration_mins: Some(300), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 900)), }), secondary: Some(RateLimitWindow { - used_percent: 30.0, - window_minutes: Some(10_080), + used_percent: 30, + window_duration_mins: Some(10_080), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)), }), credits: Some(CreditsSnapshot { @@ -1083,13 +1126,13 @@ async fn status_snapshot_shows_stale_limits_message() { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: 72.5, - window_minutes: Some(300), + used_percent: 72, + window_duration_mins: Some(300), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 600)), }), secondary: Some(RateLimitWindow { - used_percent: 40.0, - window_minutes: Some(10_080), + used_percent: 40, + window_duration_mins: Some(10_080), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_800)), }), credits: None, @@ -1150,13 +1193,13 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { - used_percent: 60.0, - window_minutes: Some(300), + used_percent: 60, + window_duration_mins: Some(300), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)), }), secondary: Some(RateLimitWindow { - used_percent: 35.0, - window_minutes: Some(10_080), + used_percent: 35, + window_duration_mins: Some(10_080), resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_400)), }), credits: Some(CreditsSnapshot { diff --git a/codex-rs/tui/src/test_support.rs b/codex-rs/tui/src/test_support.rs index 27975c354f..53fd8adf62 100644 --- a/codex-rs/tui/src/test_support.rs +++ b/codex-rs/tui/src/test_support.rs @@ -1,6 +1,40 @@ pub(crate) use codex_utils_absolute_path::test_support::PathBufExt; pub(crate) use codex_utils_absolute_path::test_support::test_path_buf; +use serde::Serialize; +use serde::de::DeserializeOwned; pub(crate) fn test_path_display(path: &str) -> String { test_path_buf(path).display().to_string() } + +pub(crate) fn session_source_cli() -> T +where + T: DeserializeOwned, +{ + from_app_server_wire(codex_app_server_protocol::SessionSource::Cli) +} + +pub(crate) fn skill_scope_user() -> T +where + T: DeserializeOwned, +{ + from_app_server_wire(codex_app_server_protocol::SkillScope::User) +} + +pub(crate) fn skill_scope_repo() -> T +where + T: DeserializeOwned, +{ + from_app_server_wire(codex_app_server_protocol::SkillScope::Repo) +} + +fn from_app_server_wire(value: impl Serialize) -> T +where + T: DeserializeOwned, +{ + serde_json::to_value(value) + .and_then(serde_json::from_value) + .unwrap_or_else(|err| { + panic!("app-server wire value should map to legacy helper type: {err}") + }) +} diff --git a/codex-rs/tui/src/token_usage.rs b/codex-rs/tui/src/token_usage.rs new file mode 100644 index 0000000000..612a09498d --- /dev/null +++ b/codex-rs/tui/src/token_usage.rs @@ -0,0 +1,119 @@ +//! TUI token usage and final-output formatting models. + +use std::fmt; + +use serde::Deserialize; +use serde::Serialize; + +const BASELINE_TOKENS: i64 = 12000; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenUsage { + pub input_tokens: i64, + pub cached_input_tokens: i64, + pub output_tokens: i64, + pub reasoning_output_tokens: i64, + pub total_tokens: i64, +} + +impl TokenUsage { + pub fn is_zero(&self) -> bool { + self.total_tokens == 0 + } + + pub(crate) fn cached_input(&self) -> i64 { + self.cached_input_tokens.max(0) + } + + pub(crate) fn non_cached_input(&self) -> i64 { + (self.input_tokens - self.cached_input()).max(0) + } + + pub(crate) fn blended_total(&self) -> i64 { + (self.non_cached_input() + self.output_tokens.max(0)).max(0) + } + + pub(crate) fn tokens_in_context_window(&self) -> i64 { + self.total_tokens + } + + pub(crate) fn percent_of_context_window_remaining(&self, context_window: i64) -> i64 { + if context_window <= BASELINE_TOKENS { + return 0; + } + let effective_window = context_window - BASELINE_TOKENS; + let used = (self.tokens_in_context_window() - BASELINE_TOKENS).max(0); + let remaining = (effective_window - used).max(0); + ((remaining as f64 / effective_window as f64) * 100.0) + .clamp(0.0, 100.0) + .round() as i64 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct TokenUsageInfo { + pub(crate) total_token_usage: TokenUsage, + pub(crate) last_token_usage: TokenUsage, + pub(crate) model_context_window: Option, +} + +#[cfg(test)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct TokenCountEvent { + pub(crate) info: Option, + pub(crate) rate_limits: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FinalOutput { + pub token_usage: TokenUsage, +} + +impl From for FinalOutput { + fn from(token_usage: TokenUsage) -> Self { + Self { token_usage } + } +} + +impl fmt::Display for FinalOutput { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let token_usage = &self.token_usage; + write!( + f, + "Token usage: total={} input={}{} output={}{}", + format_with_separators(token_usage.blended_total()), + format_with_separators(token_usage.non_cached_input()), + if token_usage.cached_input() > 0 { + format!( + " (+ {} cached)", + format_with_separators(token_usage.cached_input()) + ) + } else { + String::new() + }, + format_with_separators(token_usage.output_tokens), + if token_usage.reasoning_output_tokens > 0 { + format!( + " (reasoning {})", + format_with_separators(token_usage.reasoning_output_tokens) + ) + } else { + String::new() + } + ) + } +} + +fn format_with_separators(value: i64) -> String { + let sign = if value < 0 { "-" } else { "" }; + let digits = value.abs().to_string(); + let mut out = String::new(); + for (i, ch) in digits.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(ch); + } + let grouped: String = out.chars().rev().collect(); + format!("{sign}{grouped}") +} diff --git a/codex-rs/tui/src/tool_activity.rs b/codex-rs/tui/src/tool_activity.rs new file mode 100644 index 0000000000..b826686c02 --- /dev/null +++ b/codex-rs/tui/src/tool_activity.rs @@ -0,0 +1,163 @@ +//! Internal tool activity models used by chat cells and interrupt queueing. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use crate::diff_model::FileChange; +use codex_app_server_protocol::CommandExecOutputStream; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::HookRunSummary; +use codex_app_server_protocol::PatchApplyStatus; +use codex_protocol::mcp::CallToolResult; +use codex_protocol::parse_command::ParsedCommand; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct McpInvocation { + pub(crate) server: String, + pub(crate) tool: String, + pub(crate) arguments: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct McpToolCallBeginEvent { + pub(crate) call_id: String, + pub(crate) invocation: McpInvocation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) mcp_app_resource_uri: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct McpToolCallEndEvent { + pub(crate) call_id: String, + pub(crate) invocation: McpInvocation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) mcp_app_resource_uri: Option, + pub(crate) duration: Duration, + pub(crate) result: Result, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct WebSearchBeginEvent { + pub(crate) call_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct WebSearchEndEvent { + pub(crate) call_id: String, + pub(crate) query: String, + pub(crate) action: codex_protocol::models::WebSearchAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ImageGenerationBeginEvent { + pub(crate) call_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ImageGenerationEndEvent { + pub(crate) call_id: String, + pub(crate) status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) revised_prompt: Option, + pub(crate) result: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) saved_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ExecCommandBeginEvent { + pub(crate) call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) process_id: Option, + pub(crate) turn_id: String, + pub(crate) command: Vec, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) parsed_cmd: Vec, + #[serde(default)] + pub(crate) source: CommandExecutionSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) interaction_input: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ExecCommandEndEvent { + pub(crate) call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) process_id: Option, + pub(crate) turn_id: String, + pub(crate) command: Vec, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) parsed_cmd: Vec, + #[serde(default)] + pub(crate) source: CommandExecutionSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) interaction_input: Option, + pub(crate) stdout: String, + pub(crate) stderr: String, + #[serde(default)] + pub(crate) aggregated_output: String, + pub(crate) exit_code: i32, + pub(crate) duration: Duration, + pub(crate) formatted_output: String, + pub(crate) status: CommandExecutionStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ViewImageToolCallEvent { + pub(crate) call_id: String, + pub(crate) path: AbsolutePathBuf, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct ExecCommandOutputDeltaEvent { + pub(crate) call_id: String, + pub(crate) stream: CommandExecOutputStream, + pub(crate) chunk: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct TerminalInteractionEvent { + pub(crate) call_id: String, + pub(crate) process_id: String, + pub(crate) stdin: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PatchApplyBeginEvent { + pub(crate) call_id: String, + #[serde(default)] + pub(crate) turn_id: String, + pub(crate) auto_approved: bool, + pub(crate) changes: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PatchApplyEndEvent { + pub(crate) call_id: String, + #[serde(default)] + pub(crate) turn_id: String, + pub(crate) stdout: String, + pub(crate) stderr: String, + pub(crate) success: bool, + #[serde(default)] + pub(crate) changes: HashMap, + pub(crate) status: PatchApplyStatus, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct HookStartedEvent { + pub(crate) turn_id: Option, + pub(crate) run: HookRunSummary, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct HookCompletedEvent { + pub(crate) turn_id: Option, + pub(crate) run: HookRunSummary, +} diff --git a/codex-rs/tui/src/turn_state.rs b/codex-rs/tui/src/turn_state.rs new file mode 100644 index 0000000000..699bf7a4ac --- /dev/null +++ b/codex-rs/tui/src/turn_state.rs @@ -0,0 +1,13 @@ +//! Turn lifecycle state used by TUI interruption and completion handling. + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum TurnAbortReason { + Interrupted, + Replaced, + ReviewEnded, + BudgetLimited, +} diff --git a/codex-rs/tui/src/voice.rs b/codex-rs/tui/src/voice.rs index 229d0a8db5..ae3a1a8ad1 100644 --- a/codex-rs/tui/src/voice.rs +++ b/codex-rs/tui/src/voice.rs @@ -1,8 +1,7 @@ use crate::app_event_sender::AppEventSender; use crate::legacy_core::config::Config; use base64::Engine; -use codex_protocol::protocol::ConversationAudioParams; -use codex_protocol::protocol::RealtimeAudioFrame; +use codex_app_server_protocol::ThreadRealtimeAudioChunk; use cpal::traits::DeviceTrait; use cpal::traits::StreamTrait; use std::collections::VecDeque; @@ -219,14 +218,12 @@ fn send_realtime_audio_chunk( let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); let samples_per_channel = (samples.len() / usize::from(MODEL_AUDIO_CHANNELS)) as u32; - tx.realtime_conversation_audio(ConversationAudioParams { - frame: RealtimeAudioFrame { - data: encoded, - sample_rate: MODEL_AUDIO_SAMPLE_RATE, - num_channels: MODEL_AUDIO_CHANNELS, - samples_per_channel: Some(samples_per_channel), - item_id: None, - }, + tx.realtime_conversation_audio(ThreadRealtimeAudioChunk { + data: encoded, + sample_rate: MODEL_AUDIO_SAMPLE_RATE, + num_channels: MODEL_AUDIO_CHANNELS, + samples_per_channel: Some(samples_per_channel), + item_id: None, }); } @@ -306,7 +303,7 @@ impl RealtimeAudioPlayer { }) } - pub(crate) fn enqueue_frame(&self, frame: &RealtimeAudioFrame) -> Result<(), String> { + pub(crate) fn enqueue_frame(&self, frame: &ThreadRealtimeAudioChunk) -> Result<(), String> { if frame.num_channels == 0 || frame.sample_rate == 0 { return Err("invalid realtime audio frame format".to_string()); }